diff --git a/app/main/index.ts b/app/main/index.ts index e315831..83f6fe2 100644 --- a/app/main/index.ts +++ b/app/main/index.ts @@ -9,16 +9,32 @@ import { ZipProjectStore } from './project/zipStore'; import { registerDndAssetProtocol } from './protocol/dndAssetProtocol'; import { getAppSemanticVersion, getOptionalBuildNumber } from './versionInfo'; import { VideoPlaybackStore } from './video/videoPlaybackStore'; +import { + createBootWindow, + destroyBootWindow, + setBootWindowStatus, + waitForBootWindowReady, +} from './windows/bootWindow'; import { applyDockIconIfNeeded, closeMultiWindow, + createEditorWindowDeferred, createWindows, focusEditorWindow, markAppQuitting, openMultiWindow, togglePresentationFullscreen, + waitForEditorWindowReady, } from './windows/createWindows'; +/** + * На части конфигураций Windows окно Electron с `file://` остаётся чёрным из‑за GPU/композитора. + * Отключаем аппаратное ускорение в упакованном приложении; отключить обход: `DND_DISABLE_GPU=0`. + */ +if (process.platform === 'win32' && app.isPackaged && process.env.DND_DISABLE_GPU !== '0') { + app.disableHardwareAcceleration(); +} + if (process.platform === 'win32') { app.setAppUserModelId('com.dndplayer.app'); } @@ -89,6 +105,68 @@ function emitSessionState(): void { } } +/** + * Упакованное приложение: экран загрузки → проверки → редактор. + * В dev по умолчанию без экрана; тест: `DND_SHOW_BOOT=1`. Отключить везде: `DND_SKIP_BOOT=1`. + */ +async function runStartupAfterHandlers(licenseService: LicenseService): Promise { + const useBootSequence = + process.env.DND_SKIP_BOOT !== '1' && (app.isPackaged || process.env.DND_SHOW_BOOT === '1'); + + if (!useBootSequence) { + createWindows(); + emitSessionState(); + emitEffectsState(); + emitVideoState(); + return; + } + + const splash = createBootWindow(); + try { + await waitForBootWindowReady(splash); + } catch (err) { + console.error('[boot] splash load failed', err); + destroyBootWindow(splash); + createWindows(); + emitSessionState(); + emitEffectsState(); + emitVideoState(); + return; + } + + splash.show(); + setBootWindowStatus(splash, 'Инициализация…'); + + try { + setBootWindowStatus(splash, 'Подготовка данных…'); + await projectStore.ensureRoots(); + } catch (e) { + console.error('[boot] ensureRoots', e); + } + + setBootWindowStatus(splash, 'Устанавливаем связь…'); + setBootWindowStatus(splash, 'Проверка лицензии…'); + try { + await licenseService.getStatus(); + } catch (e) { + console.error('[boot] license getStatus', e); + } + + setBootWindowStatus(splash, 'Загрузка редактора…'); + const editor = createEditorWindowDeferred(); + await waitForEditorWindowReady(editor); + setBootWindowStatus(splash, 'Готово'); + destroyBootWindow(splash); + if (!editor.isDestroyed()) { + editor.show(); + editor.focus(); + } + + emitSessionState(); + emitEffectsState(); + emitVideoState(); +} + async function main() { await app.whenReady(); const licenseService = new LicenseService(app.getPath('userData')); @@ -334,10 +412,7 @@ async function main() { installIpcRouter(); applyDockIconIfNeeded(); - createWindows(); - emitSessionState(); - emitEffectsState(); - emitVideoState(); + await runStartupAfterHandlers(licenseService); app.on('activate', () => { focusEditorWindow(); diff --git a/app/main/windows/bootWindow.test.ts b/app/main/windows/bootWindow.test.ts new file mode 100644 index 0000000..e5bf01c --- /dev/null +++ b/app/main/windows/bootWindow.test.ts @@ -0,0 +1,28 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const here = path.dirname(fileURLToPath(import.meta.url)); + +function readBootWindow(): string { + return fs.readFileSync(path.join(here, 'bootWindow.ts'), 'utf8'); +} + +void test('bootWindow: экран загрузки без preload, статус из main', () => { + const src = readBootWindow(); + assert.ok(src.includes('createBootWindow')); + assert.ok(src.includes('destroyBootWindow')); + assert.ok(src.includes('setBootWindowStatus')); + assert.ok(src.includes('waitForBootWindowReady')); + assert.ok(src.includes('executeJavaScript')); +}); + +void test('createWindows: отложенный показ редактора для boot', () => { + const src = fs.readFileSync(path.join(here, 'createWindows.ts'), 'utf8'); + assert.ok(src.includes('deferVisibility')); + assert.ok(src.includes('createEditorWindowDeferred')); + assert.ok(src.includes('waitForEditorWindowReady')); + assert.ok(src.includes('getBootSplashWindow')); +}); diff --git a/app/main/windows/bootWindow.ts b/app/main/windows/bootWindow.ts new file mode 100644 index 0000000..8be096c --- /dev/null +++ b/app/main/windows/bootWindow.ts @@ -0,0 +1,115 @@ +import path from 'node:path'; + +import { app, BrowserWindow } from 'electron'; + +import { getAppSemanticVersion } from '../versionInfo'; + +let bootSplashRef: BrowserWindow | null = null; + +export function getBootSplashWindow(): BrowserWindow | null { + return bootSplashRef; +} + +function loadBootPage(win: BrowserWindow): void { + const dev = process.env.VITE_DEV_SERVER_URL; + if (dev) { + void win.loadURL(new URL('boot.html', dev).toString()); + return; + } + const htmlPath = path.join(app.getAppPath(), 'dist', 'renderer', 'boot.html'); + void win.loadFile(htmlPath); +} + +/** Без preload: только статический экран; статус задаётся из main через executeJavaScript. */ +function bootWebPreferences(): Electron.WebPreferences { + const dev = Boolean(process.env.VITE_DEV_SERVER_URL); + return { + contextIsolation: true, + sandbox: dev ? true : process.platform !== 'win32', + nodeIntegration: false, + devTools: dev || process.env.DND_OPEN_DEVTOOLS === '1', + webSecurity: dev, + }; +} + +/** + * Окно без системного заголовка: логотип, название, строка статуса. + * Показывать после `waitForBootWindowReady`. + */ +export function createBootWindow(): BrowserWindow { + const win = new BrowserWindow({ + width: 440, + height: 420, + show: false, + frame: false, + resizable: false, + maximizable: false, + minimizable: false, + closable: false, + center: true, + transparent: false, + backgroundColor: '#09090B', + roundedCorners: true, + webPreferences: bootWebPreferences(), + }); + + bootSplashRef = win; + win.once('closed', () => { + if (bootSplashRef === win) { + bootSplashRef = null; + } + }); + + loadBootPage(win); + return win; +} + +/** + * Закрыть splash: при `closable: false` на Windows `close()` из main часто не срабатывает — используем `destroy()`. + */ +export function destroyBootWindow(win: BrowserWindow): void { + if (win.isDestroyed()) return; + win.destroy(); +} + +export function setBootWindowStatus(win: BrowserWindow, text: string): void { + if (win.isDestroyed()) return; + const escaped = JSON.stringify(text); + void win.webContents.executeJavaScript( + `(() => { const el = document.getElementById('boot-status'); if (el) el.textContent = ${escaped}; })()`, + ); +} + +export function applyBootWindowBranding(win: BrowserWindow): void { + if (win.isDestroyed()) return; + const name = app.getName(); + const version = getAppSemanticVersion(); + const versionLabel = version.trim().length > 0 ? `v${version.trim()}` : ''; + void win.webContents.executeJavaScript( + `(() => { + const t = document.querySelector('[data-boot-title]'); + if (t) t.textContent = ${JSON.stringify(name)}; + const v = document.querySelector('[data-boot-version]'); + if (v) v.textContent = ${JSON.stringify(versionLabel)}; + })()`, + ); +} + +/** Дождаться загрузки разметки экрана загрузки. */ +export function waitForBootWindowReady(win: BrowserWindow): Promise { + return new Promise((resolve, reject) => { + if (win.isDestroyed()) { + reject(new Error('boot window destroyed')); + return; + } + const onFail = (): void => { + reject(new Error('boot window failed to load')); + }; + win.webContents.once('did-fail-load', onFail); + win.webContents.once('did-finish-load', () => { + win.webContents.removeListener('did-fail-load', onFail); + applyBootWindowBranding(win); + resolve(); + }); + }); +} diff --git a/app/main/windows/createWindows.editorClose.test.ts b/app/main/windows/createWindows.editorClose.test.ts index ce93bf2..9bc0c52 100644 --- a/app/main/windows/createWindows.editorClose.test.ts +++ b/app/main/windows/createWindows.editorClose.test.ts @@ -33,3 +33,15 @@ void test('createWindows: пульт поверх экрана просмотр assert.ok(src.includes('parent: presentation')); assert.ok(src.includes("createWindow('control'")); }); + +void test('createWindows: production — loadFile для HTML (не только file://)', () => { + const src = readCreateWindows(); + assert.ok(src.includes('loadFile')); + assert.ok(src.includes('loadWindowPage')); +}); + +void test('createWindows: показ окна — не только ready-to-show (холодный старт Windows)', () => { + const src = readCreateWindows(); + assert.ok(src.includes('ensureWindowBecomesVisible')); + assert.ok(src.includes('did-finish-load')); +}); diff --git a/app/main/windows/createWindows.ts b/app/main/windows/createWindows.ts index 4398120..19b3de8 100644 --- a/app/main/windows/createWindows.ts +++ b/app/main/windows/createWindows.ts @@ -1,9 +1,10 @@ import fs from 'node:fs'; import path from 'node:path'; -import { pathToFileURL } from 'node:url'; import { app, BrowserWindow, nativeImage, screen } from 'electron'; +import { getBootSplashWindow } from './bootWindow'; + type WindowKind = 'editor' | 'presentation' | 'control'; const windows = new Map(); @@ -24,15 +25,29 @@ function isDev() { return process.env.NODE_ENV === 'development' || process.env.VITE_DEV_SERVER_URL !== undefined; } -function getRendererUrl(kind: WindowKind): string { +/** Вне 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`); +} + +/** + * В 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) { const page = kind === 'editor' ? 'editor.html' : kind === 'presentation' ? 'presentation.html' : 'control.html'; - return new URL(page, dev).toString(); + void win.loadURL(new URL(page, dev).toString()); + return; } - const filePath = path.join(app.getAppPath(), 'dist', 'renderer', `${kind}.html`); - return pathToFileURL(filePath).toString(); + void win.loadFile(getRendererHtmlPath(kind)); } function getPreloadPath(): string { @@ -121,9 +136,35 @@ export function applyDockIconIfNeeded(): void { 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 createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow { + const deferEditor = kind === 'editor' && opts?.deferVisibility === true; const icon = resolveWindowIcon(); const win = new BrowserWindow({ width: kind === 'editor' ? 1280 : kind === 'control' ? 1200 : 1280, @@ -134,11 +175,13 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow ...(opts?.parent ? { parent: opts.parent } : {}), webPreferences: { contextIsolation: true, - sandbox: true, + sandbox: shouldUseRendererSandbox(), nodeIntegration: false, - devTools: isDev(), + 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), }, }); @@ -148,9 +191,14 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow win.webContents.on('did-fail-load', (_event, errorCode, errorDescription, validatedURL) => { console.error(`[did-fail-load] ${String(errorCode)} ${errorDescription} ${validatedURL}`); }); + win.webContents.on('render-process-gone', (_event, details) => { + console.error('[render-process-gone]', details.reason, details.exitCode); + }); - win.once('ready-to-show', () => win.show()); - void win.loadURL(getRendererUrl(kind)); + if (!deferEditor) { + ensureWindowBecomesVisible(win); + } + loadWindowPage(win, kind); if (kind === 'editor') { win.on('close', (e) => { if (appQuitting) return; @@ -169,7 +217,47 @@ export function createWindows() { } } +/** Редактор создаётся скрытым до окончания экрана загрузки. */ +export function createEditorWindowDeferred(): BrowserWindow { + const existing = windows.get('editor'); + if (existing) { + return existing; + } + return createWindow('editor', { deferVisibility: true }); +} + +/** Дождаться первой отрисовки редактора (готовность к показу без чёрного экрана). */ +export function waitForEditorWindowReady(win: BrowserWindow): Promise { + return new Promise((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((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(); diff --git a/app/renderer/boot.html b/app/renderer/boot.html new file mode 100644 index 0000000..9a5b418 --- /dev/null +++ b/app/renderer/boot.html @@ -0,0 +1,124 @@ + + + + + + + Загрузка + + + +
+
+ +

DNDGamePlayer

+

редактор и проигрыватель

+

+

Запуск…

+ +
+
+ + diff --git a/app/renderer/control/ControlApp.module.css b/app/renderer/control/ControlApp.module.css index b161c07..b587dc6 100644 --- a/app/renderer/control/ControlApp.module.css +++ b/app/renderer/control/ControlApp.module.css @@ -222,6 +222,7 @@ .brushCursor { position: absolute; z-index: 2; + will-change: left, top; transform: translate(-50%, -50%); border-radius: 50%; border: 1px solid rgba(255, 255, 255, 0.55); diff --git a/app/renderer/control/ControlApp.tsx b/app/renderer/control/ControlApp.tsx index 1717f35..b472309 100644 --- a/app/renderer/control/ControlApp.tsx +++ b/app/renderer/control/ControlApp.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo, useRef, useState } from 'react'; +import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { pickEraseTargetId } from '../../shared/effectEraserHitTest'; import { ipcChannels } from '../../shared/ipc/contracts'; @@ -62,7 +62,6 @@ export function ControlApp() { points?: { x: number; y: number; tMs: number }[]; } | null>(null); const [draftFxTick, setDraftFxTick] = useState(0); - const [cursorN, setCursorN] = useState<{ x: number; y: number } | null>(null); const [previewSize, setPreviewSize] = useState<{ w: number; h: number }>({ w: 1, h: 1 }); const [previewContentRect, setPreviewContentRect] = useState<{ x: number; @@ -70,6 +69,13 @@ export function ControlApp() { w: number; h: number; } | null>(null); + const previewContentRectRef = useRef(previewContentRect); + previewContentRectRef.current = previewContentRect; + const previewSizeRef = useRef(previewSize); + previewSizeRef.current = previewSize; + const brushCursorElRef = useRef(null); + const cursorPosRef = useRef<{ x: number; y: number } | null>(null); + const draftPaintRafRef = useRef(0); useEffect(() => { void api.invoke(ipcChannels.project.get, {}).then((res) => { @@ -351,12 +357,58 @@ export function ControlApp() { }, [currentGraphNodeId, project]); const tool = fxState?.tool ?? { tool: 'fog', radiusN: 0.08, intensity: 0.6 }; + const toolRef = useRef(tool); + toolRef.current = tool; + + function layoutBrushCursor(): void { + const el = brushCursorElRef.current; + const p = cursorPosRef.current; + const cr = previewContentRectRef.current; + const ps = previewSizeRef.current; + const t = toolRef.current; + if (!el) return; + if (!p) { + el.style.visibility = 'hidden'; + return; + } + el.style.visibility = 'visible'; + const ox = cr ? cr.x : 0; + const oy = cr ? cr.y : 0; + const cw = cr ? cr.w : ps.w; + const ch = cr ? cr.h : ps.h; + const minDim = Math.min(cw, ch); + const size = Math.max(2, t.radiusN * minDim * 2); + el.style.left = `${String(ox + p.x * cw)}px`; + el.style.top = `${String(oy + p.y * ch)}px`; + el.style.width = `${String(size)}px`; + el.style.height = `${String(size)}px`; + } + + function scheduleDraftRepaint(): void { + if (draftPaintRafRef.current !== 0) return; + draftPaintRafRef.current = requestAnimationFrame(() => { + draftPaintRafRef.current = 0; + setDraftFxTick((x) => x + 1); + }); + } + + useLayoutEffect(() => { + layoutBrushCursor(); + }, [tool.radiusN, previewContentRect, previewSize.w, previewSize.h]); + + useEffect(() => { + return () => { + if (draftPaintRafRef.current !== 0) { + cancelAnimationFrame(draftPaintRafRef.current); + } + }; + }, []); function toNPoint(e: React.PointerEvent): { x: number; y: number } | null { const host = previewHostRef.current; if (!host) return null; const r = host.getBoundingClientRect(); - const cr = previewContentRect; + const cr = previewContentRectRef.current; const ox = cr ? cr.x : 0; const oy = cr ? cr.y : 0; const cw = cr ? cr.w : r.width; @@ -914,45 +966,29 @@ export function ControlApp() { : undefined } /> - {cursorN ? ( -
- ) : null} +
{ const p = toNPoint(e); if (!p) return; - setCursorN(p); + cursorPosRef.current = p; + layoutBrushCursor(); + }} + onPointerLeave={() => { + cursorPosRef.current = null; + layoutBrushCursor(); }} - onPointerLeave={() => setCursorN(null)} onPointerDown={(e) => { const p = toNPoint(e); if (!p) return; - setCursorN(p); + cursorPosRef.current = p; + layoutBrushCursor(); (e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId); if (tool.tool === 'eraser') { const id = pickEraseTargetId(fxState?.instances ?? [], p, tool.radiusN); @@ -969,7 +1005,8 @@ export function ControlApp() { onPointerMove={(e) => { const p = toNPoint(e); if (!p) return; - setCursorN(p); + cursorPosRef.current = p; + layoutBrushCursor(); if (tool.tool === 'eraser' && (e.buttons & 1) !== 0) { const id = pickEraseTargetId(fxState?.instances ?? [], p, tool.radiusN); if (id) void fx.dispatch({ kind: 'instance.remove', id }); @@ -984,7 +1021,7 @@ export function ControlApp() { const minStep = Math.max(0.004, tool.radiusN * 0.25); if (dx * dx + dy * dy < minStep * minStep) return; b.points.push({ x: p.x, y: p.y, tMs: Date.now() }); - setDraftFxTick((x) => x + 1); + scheduleDraftRepaint(); }} onPointerUp={() => { void commitStroke(); diff --git a/app/renderer/shared/effects/PxiEffectsOverlay.module.css b/app/renderer/shared/effects/PxiEffectsOverlay.module.css index 48bca29..a2defe0 100644 --- a/app/renderer/shared/effects/PxiEffectsOverlay.module.css +++ b/app/renderer/shared/effects/PxiEffectsOverlay.module.css @@ -1,6 +1,7 @@ .host { position: absolute; inset: 0; + contain: layout paint; } .hostInteractive { diff --git a/app/renderer/shared/effects/PxiEffectsOverlay.pointer.test.ts b/app/renderer/shared/effects/PxiEffectsOverlay.pointer.test.ts index 8b1faef..0d11e18 100644 --- a/app/renderer/shared/effects/PxiEffectsOverlay.pointer.test.ts +++ b/app/renderer/shared/effects/PxiEffectsOverlay.pointer.test.ts @@ -10,3 +10,8 @@ void test('PxiEffectsOverlay: canvas не перехватывает указа const src = fs.readFileSync(path.join(here, 'PxiEffectsOverlay.tsx'), 'utf8'); assert.ok(src.includes("app.canvas.style.pointerEvents = interactive ? 'auto' : 'none'")); }); + +void test('PxiEffectsOverlay: ограничение FPS тикера для нагрузки', () => { + const src = fs.readFileSync(path.join(here, 'PxiEffectsOverlay.tsx'), 'utf8'); + assert.ok(src.includes('app.ticker.maxFPS')); +}); diff --git a/app/renderer/shared/effects/PxiEffectsOverlay.tsx b/app/renderer/shared/effects/PxiEffectsOverlay.tsx index 1711f1a..dc2057f 100644 --- a/app/renderer/shared/effects/PxiEffectsOverlay.tsx +++ b/app/renderer/shared/effects/PxiEffectsOverlay.tsx @@ -41,13 +41,15 @@ export function PixiEffectsOverlay({ state, interactive = false, style, viewport const viewportRef = useRef<{ x: number; y: number; w: number; h: number }>({ x: 0, y: 0, w: 1, h: 1 }); const viewportProvidedRef = useRef(false); - const dpr = useMemo(() => Math.min(2, window.devicePixelRatio || 1), []); + /** Снижаем resolution на HiDPI — меньше пикселей в WebGL, визуально ок для оверлея эффектов. */ + const dpr = useMemo(() => Math.min(1.5, window.devicePixelRatio || 1), []); useEffect(() => { const host = hostRef.current; if (!host) return; let destroyed = false; + let resizeRaf = 0; let app: any = null; let cleanup: (() => void) | null = null; void (async () => { @@ -58,10 +60,14 @@ export function PixiEffectsOverlay({ state, interactive = false, style, viewport app = new pixi.Application(); await app.init({ backgroundAlpha: 0, - antialias: true, + antialias: false, + powerPreference: 'high-performance', resolution: dpr, autoDensity: true, + preference: 'webgl', }); + // Меньше кадров — меньше CPU/GPU; анимации эффектов остаются плавными. + app.ticker.maxFPS = 32; if (destroyed) return; host.appendChild(app.canvas); // Canvas по умолчанию перехватывает hit-test; оставляем клики «сквозь» оверлей для слоя кисти сверху. @@ -72,13 +78,16 @@ export function PixiEffectsOverlay({ state, interactive = false, style, viewport app.stage.addChild(root); const ro = new ResizeObserver(() => { - const r = host.getBoundingClientRect(); - app.renderer.resize(Math.max(1, Math.floor(r.width)), Math.max(1, Math.floor(r.height))); - sizeRef.current = { w: app.renderer.width, h: app.renderer.height }; - if (!viewportProvidedRef.current) { - viewportRef.current = { x: 0, y: 0, w: sizeRef.current.w, h: sizeRef.current.h }; - } - syncNodes(pixi, root, nodesRef.current, stateRef.current, sizeRef.current, viewportRef.current); + cancelAnimationFrame(resizeRaf); + resizeRaf = requestAnimationFrame(() => { + const r = host.getBoundingClientRect(); + app.renderer.resize(Math.max(1, Math.floor(r.width)), Math.max(1, Math.floor(r.height))); + sizeRef.current = { w: app.renderer.width, h: app.renderer.height }; + if (!viewportProvidedRef.current) { + viewportRef.current = { x: 0, y: 0, w: sizeRef.current.w, h: sizeRef.current.h }; + } + syncNodes(pixi, root, nodesRef.current, stateRef.current, sizeRef.current, viewportRef.current); + }); }); ro.observe(host); @@ -117,6 +126,7 @@ export function PixiEffectsOverlay({ state, interactive = false, style, viewport return () => { destroyed = true; + cancelAnimationFrame(resizeRaf); cleanup?.(); const a = appRef.current; appRef.current = null; @@ -378,7 +388,8 @@ function createInstanceNode( if (inst.type === 'poisonCloud') { const cont = new pixi.Container(); const tex = getPoisonParticleTexture(pixi); - const particleCount = 520; + /** Меньше спрайтов — быстрее тик; картина остаётся плотной. */ + const particleCount = 400; const particles: PoisonParticleFx[] = []; for (let i = 0; i < particleCount; i += 1) { const s = new pixi.Sprite(tex); diff --git a/app/shared/package.build.test.ts b/app/shared/package.build.test.ts index 62f2bde..014506e 100644 --- a/app/shared/package.build.test.ts +++ b/app/shared/package.build.test.ts @@ -8,11 +8,19 @@ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '. void test('package.json: конфиг electron-builder (mac/win)', () => { const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')) as { - build: { appId: string; asar: boolean; mac: { target: unknown }; files: string[] }; + build: { + appId: string; + asar: boolean; + asarUnpack: string[]; + mac: { target: unknown }; + files: string[]; + }; }; assert.ok(pkg.build); assert.equal(pkg.build.appId, 'com.dndplayer.app'); assert.equal(pkg.build.asar, true, 'релизный артефакт: app.asar без «голого» дерева dist в .app/.exe'); + assert.ok(Array.isArray(pkg.build.asarUnpack)); + assert.ok(pkg.build.asarUnpack.some((p) => p.includes('preload'))); assert.ok(Array.isArray(pkg.build.mac.target)); assert.ok(pkg.build.files.includes('dist/**/*')); }); diff --git a/eslint.config.js b/eslint.config.js index bdeb91d..87f2513 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -12,7 +12,7 @@ const tsProject = ['./tsconfig.eslint.json']; export default tseslint.config( { - ignores: ['dist/**', 'node_modules/**', '.cursor/**', 'scripts/**', 'eslint.config.js'], + ignores: ['dist/**', 'release/**', 'node_modules/**', '.cursor/**', 'scripts/**', 'eslint.config.js'], }, js.configs.recommended, ...tseslint.configs.strictTypeChecked, diff --git a/package.json b/package.json index eda230a..33b4c82 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "build:obfuscate": "node scripts/build.mjs --production --obfuscate", "lint": "eslint . --max-warnings 0", "typecheck": "tsc -p tsconfig.eslint.json --noEmit", - "test": "tsx --test app/renderer/shared/ui/controls.tooltip.test.ts app/shared/ipc/contracts.mediaRemoval.test.ts app/shared/effectEraserHitTest.test.ts app/renderer/control/controlApp.effectsPanel.test.ts app/renderer/shared/effects/PxiEffectsOverlay.pointer.test.ts app/main/windows/createWindows.editorClose.test.ts app/main/effects/effectsStore.test.ts app/main/project/assetPrune.test.ts app/main/project/zipRead.test.ts app/shared/package.build.test.ts app/shared/license/canonicalJson.test.ts app/shared/license/productKey.test.ts app/main/license/verifyLicenseToken.test.ts && node --test scripts/build-env.test.mjs scripts/obfuscate-main.test.mjs", + "test": "tsx --test app/renderer/shared/ui/controls.tooltip.test.ts app/shared/ipc/contracts.mediaRemoval.test.ts app/shared/effectEraserHitTest.test.ts app/renderer/control/controlApp.effectsPanel.test.ts app/renderer/shared/effects/PxiEffectsOverlay.pointer.test.ts app/main/windows/createWindows.editorClose.test.ts app/main/windows/bootWindow.test.ts app/main/effects/effectsStore.test.ts app/main/project/assetPrune.test.ts app/main/project/zipRead.test.ts app/shared/package.build.test.ts app/shared/license/canonicalJson.test.ts app/shared/license/productKey.test.ts app/main/license/verifyLicenseToken.test.ts && node --test scripts/build-env.test.mjs scripts/obfuscate-main.test.mjs", "format": "prettier . --check", "format:write": "prettier . --write", "release:info": "node scripts/print-release-info.mjs", @@ -74,6 +74,9 @@ "package.json" ], "asar": true, + "asarUnpack": [ + "dist/preload/**" + ], "mac": { "category": "public.app-category.games", "target": [ diff --git a/vite.config.ts b/vite.config.ts index f2eb792..d5fbe62 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -2,7 +2,22 @@ import path from 'node:path'; import strip from '@rollup/plugin-strip'; import react from '@vitejs/plugin-react'; -import { defineConfig } from 'vite'; +import { defineConfig, type Plugin } from 'vite'; + +/** + * Vite в проде вешает `crossorigin` на script/link; при открытии HTML через `file://` в Electron + * на Windows это часто приводит к тихому отказу загрузки ES-модулей (чёрный экран). macOS может «проглатывать». + */ +function stripCrossoriginForElectronFile(): Plugin { + return { + name: 'strip-crossorigin-electron-file', + enforce: 'post', + apply: 'build', + transformIndexHtml(html) { + return html.replace(/\s+crossorigin(?:=["']?[^"'>\s]+["']?)?/gi, ''); + }, + }; +} export default defineConfig(({ mode }) => { const isProd = mode === 'production'; @@ -17,6 +32,7 @@ export default defineConfig(({ mode }) => { plugins: [['babel-plugin-react-compiler', { target: '19' }]], }, } as Parameters[0]), + ...(isProd ? [stripCrossoriginForElectronFile()] : []), ], build: { outDir: path.resolve(__dirname, 'dist/renderer'), @@ -33,6 +49,7 @@ export default defineConfig(({ mode }) => { ] : [], input: { + boot: path.resolve(__dirname, 'app/renderer/boot.html'), editor: path.resolve(__dirname, 'app/renderer/editor.html'), presentation: path.resolve(__dirname, 'app/renderer/presentation.html'), control: path.resolve(__dirname, 'app/renderer/control.html'),