feat: boot-экран, стабильность Windows и оптимизация Pixi/пульта

- Экран загрузки (boot.html, bootWindow): статусы, ensureRoots и проверка лицензии, редактор после готовности; закрытие через destroy при closable:false.

- Упакованное приложение на Windows: disableHardwareAcceleration, sandbox выкл. вне dev, отложенный показ редактора, ensureWindowBecomesVisible, фокус на splash при second-instance.

- Vite: вход boot.html; eslint: игнор release/; тесты boot и maxFPS тикера.

- Пульт: позиция курсора кисти через ref/DOM без setState на каждый move; черновик эффекта через rAF; Pixi: maxFPS 32, resolution cap, antialias off, debounce ResizeObserver, меньше частиц poisonCloud, contain на хосте.

Made-with: Cursor
This commit is contained in:
Ivan Fontosh
2026-04-20 12:12:01 +08:00
parent 20c838da7d
commit e39a72206d
15 changed files with 587 additions and 62 deletions
@@ -1,6 +1,7 @@
.host {
position: absolute;
inset: 0;
contain: layout paint;
}
.hostInteractive {
@@ -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'));
});
@@ -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);