perf: cut ControlApp re-renders and lazy-load VFX packs

Move audio scrub/volume and brush drafts off root React ticks, coalesce overlay layout IPC, cache machine fingerprint and asset URLs, share one scene overlay host, and stop idle Pixi ticker. Also harden console against EPIPE on window load failures.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-07-23 11:58:16 +08:00
parent 32a5479086
commit d9fbecf5a7
29 changed files with 1825 additions and 585 deletions
+4
View File
@@ -3,6 +3,10 @@ import path from 'node:path';
import { app, BrowserWindow, dialog, Menu, protocol } from 'electron'; import { app, BrowserWindow, dialog, Menu, protocol } from 'electron';
import { installStdoutEpipeGuards } from './safeConsole';
installStdoutEpipeGuards();
import { openDialogFilterLabel } from '../shared/appBranding'; import { openDialogFilterLabel } from '../shared/appBranding';
import { ipcChannels, type ScenePreviewImportEvent, type SessionState } from '../shared/ipc/contracts'; import { ipcChannels, type ScenePreviewImportEvent, type SessionState } from '../shared/ipc/contracts';
import { import {
+3 -2
View File
@@ -25,7 +25,8 @@ export function clearLegacyDeviceId(userData: string): void {
/** /**
* Идентификатор устройства для лицензии: отпечаток физической машины. * Идентификатор устройства для лицензии: отпечаток физической машины.
* Одинаков для всех пользователей ОС на одном ПК (Windows/macOS/Linux). * Одинаков для всех пользователей ОС на одном ПК (Windows/macOS/Linux).
* `userData` — путь для дискового кэша fingerprint (без повторного reg/wmic).
*/ */
export function getOrCreateDeviceId(_userData?: string): string { export function getOrCreateDeviceId(userData?: string): string {
return resolveMachineFingerprint(); return resolveMachineFingerprint(userData ? { userData } : {});
} }
@@ -5,6 +5,7 @@ import path from 'node:path';
import test from 'node:test'; import test from 'node:test';
import { import {
clearMachineFingerprintMemoryCache,
hashMachineRawId, hashMachineRawId,
machineWideIdPath, machineWideIdPath,
parseMacIOPlatformUUID, parseMacIOPlatformUUID,
@@ -12,6 +13,7 @@ import {
parseWmicUuid, parseWmicUuid,
resolveMachineFingerprint, resolveMachineFingerprint,
} from './machineFingerprint'; } from './machineFingerprint';
import { machineFingerprintCachePath } from './paths';
void test('hashMachineRawId: стабилен и не зависит от регистра GUID', () => { void test('hashMachineRawId: стабилен и не зависит от регистра GUID', () => {
const a = hashMachineRawId('win32', 'ABCDEF00-1111-2222-3333-444455556666'); const a = hashMachineRawId('win32', 'ABCDEF00-1111-2222-3333-444455556666');
@@ -46,6 +48,7 @@ void test('parseWmicUuid', () => {
}); });
void test('resolveMachineFingerprint: override через DND_LICENSE_DEVICE_ID', () => { void test('resolveMachineFingerprint: override через DND_LICENSE_DEVICE_ID', () => {
clearMachineFingerprintMemoryCache();
const id = resolveMachineFingerprint({ const id = resolveMachineFingerprint({
platform: 'linux', platform: 'linux',
env: { DND_LICENSE_DEVICE_ID: 'override-device-id-12345' }, env: { DND_LICENSE_DEVICE_ID: 'override-device-id-12345' },
@@ -54,6 +57,7 @@ void test('resolveMachineFingerprint: override через DND_LICENSE_DEVICE_ID'
}); });
void test('resolveMachineFingerprint: Windows MachineGuid → одинаковый hash', () => { void test('resolveMachineFingerprint: Windows MachineGuid → одинаковый hash', () => {
clearMachineFingerprintMemoryCache();
const exec = () => const exec = () =>
` `
HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Cryptography HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Cryptography
@@ -64,6 +68,7 @@ HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Cryptography
env: {}, env: {},
execFileSync: exec as never, execFileSync: exec as never,
}); });
clearMachineFingerprintMemoryCache();
const b = resolveMachineFingerprint({ const b = resolveMachineFingerprint({
platform: 'win32', platform: 'win32',
env: {}, env: {},
@@ -74,6 +79,7 @@ HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Cryptography
}); });
void test('resolveMachineFingerprint: Linux /etc/machine-id', () => { void test('resolveMachineFingerprint: Linux /etc/machine-id', () => {
clearMachineFingerprintMemoryCache();
const id = resolveMachineFingerprint({ const id = resolveMachineFingerprint({
platform: 'linux', platform: 'linux',
env: {}, env: {},
@@ -86,6 +92,7 @@ void test('resolveMachineFingerprint: Linux /etc/machine-id', () => {
}); });
void test('resolveMachineFingerprint: fallback в machine-wide путь', () => { void test('resolveMachineFingerprint: fallback в machine-wide путь', () => {
clearMachineFingerprintMemoryCache();
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'machine-fp-')); const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'machine-fp-'));
const env = { PROGRAMDATA: tmp }; const env = { PROGRAMDATA: tmp };
const p = machineWideIdPath('win32', env); const p = machineWideIdPath('win32', env);
@@ -96,6 +103,7 @@ void test('resolveMachineFingerprint: fallback в machine-wide путь', () =>
throw new Error('no reg'); throw new Error('no reg');
}, },
}); });
clearMachineFingerprintMemoryCache();
const id2 = resolveMachineFingerprint({ const id2 = resolveMachineFingerprint({
platform: 'win32', platform: 'win32',
env, env,
@@ -107,3 +115,37 @@ void test('resolveMachineFingerprint: fallback в machine-wide путь', () =>
assert.ok(fs.existsSync(p)); assert.ok(fs.existsSync(p));
fs.rmSync(tmp, { recursive: true, force: true }); fs.rmSync(tmp, { recursive: true, force: true });
}); });
void test('resolveMachineFingerprint: disk cache — без повторного exec', () => {
clearMachineFingerprintMemoryCache();
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'machine-fp-cache-'));
const expected = hashMachineRawId('win32', 'A1B2C3D4-E5F6-7890-ABCD-EF1234567890');
let execCalls = 0;
const exec = () => {
execCalls += 1;
return `
HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Cryptography
MachineGuid REG_SZ A1B2C3D4-E5F6-7890-ABCD-EF1234567890
`;
};
const a = resolveMachineFingerprint({
platform: 'win32',
env: {},
userData: tmp,
execFileSync: exec as never,
});
assert.equal(a, expected);
assert.equal(execCalls, 1);
assert.ok(fs.existsSync(machineFingerprintCachePath(tmp)));
clearMachineFingerprintMemoryCache();
const b = resolveMachineFingerprint({
platform: 'win32',
env: {},
userData: tmp,
execFileSync: exec as never,
});
assert.equal(b, expected);
assert.equal(execCalls, 1, 'второй вызов читает disk cache, без reg/wmic');
fs.rmSync(tmp, { recursive: true, force: true });
});
+69 -2
View File
@@ -4,6 +4,8 @@ import fs from 'node:fs';
import os from 'node:os'; import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
import { machineFingerprintCachePath } from './paths';
type ExecFile = ( type ExecFile = (
file: string, file: string,
args: readonly string[], args: readonly string[],
@@ -13,6 +15,8 @@ type ExecFile = (
export type MachineFingerprintDeps = { export type MachineFingerprintDeps = {
platform?: NodeJS.Platform; platform?: NodeJS.Platform;
env?: NodeJS.ProcessEnv; env?: NodeJS.ProcessEnv;
/** Electron userData — для дискового кэша hashed fingerprint. */
userData?: string;
execFileSync?: ExecFile; execFileSync?: ExecFile;
readFileSync?: (p: string, encoding: 'utf8') => string; readFileSync?: (p: string, encoding: 'utf8') => string;
existsSync?: (p: string) => boolean; existsSync?: (p: string) => boolean;
@@ -20,6 +24,18 @@ export type MachineFingerprintDeps = {
writeFileSync?: (p: string, data: string, opts?: { mode?: number }) => void; writeFileSync?: (p: string, data: string, opts?: { mode?: number }) => void;
}; };
/** Process-level кэш: повторные вызовы в том же процессе без I/O. */
let memoryFingerprint: string | null = null;
/** Только для тестов. */
export function clearMachineFingerprintMemoryCache(): void {
memoryFingerprint = null;
}
function isPlausiblyFingerprint(id: string): boolean {
return id.length >= 8 && id.length <= 128 && !/\s/.test(id);
}
const HASH_PREFIX = 'TTRPGPlayer.machine.v1\0'; const HASH_PREFIX = 'TTRPGPlayer.machine.v1\0';
/** Стабильный opaque id из сырого машинного идентификатора ОС. */ /** Стабильный opaque id из сырого машинного идентификатора ОС. */
@@ -149,9 +165,38 @@ function readOrCreateMachineWideFallback(
} }
} }
function readDiskFingerprintCache(
cacheFile: string,
readFile: (p: string, encoding: 'utf8') => string,
existsSync: (p: string) => boolean,
): string | null {
try {
if (!existsSync(cacheFile)) return null;
const cached = readFile(cacheFile, 'utf8').trim();
return isPlausiblyFingerprint(cached) ? cached : null;
} catch {
return null;
}
}
function writeDiskFingerprintCache(
cacheFile: string,
fingerprint: string,
mkdirSync: (p: string, opts: { recursive: boolean }) => void,
writeFileSync: (p: string, data: string, opts?: { mode?: number }) => void,
): void {
try {
mkdirSync(path.dirname(cacheFile), { recursive: true });
writeFileSync(cacheFile, `${fingerprint}\n`, { mode: 0o644 });
} catch {
/* кэш необязателен */
}
}
/** /**
* Стабильный идентификатор физической машины (одинаковый для всех пользователей ОС на одном ПК). * Стабильный идентификатор физической машины (одинаковый для всех пользователей ОС на одном ПК).
* Источники: Windows MachineGuid, macOS IOPlatformUUID, Linux /etc/machine-id. * Источники: Windows MachineGuid, macOS IOPlatformUUID, Linux /etc/machine-id.
* Кэш: память процесса → userData/machine.fingerprint → sync probe ОС только при miss.
*/ */
export function resolveMachineFingerprint(deps: MachineFingerprintDeps = {}): string { export function resolveMachineFingerprint(deps: MachineFingerprintDeps = {}): string {
const platform = deps.platform ?? process.platform; const platform = deps.platform ?? process.platform;
@@ -167,7 +212,24 @@ export function resolveMachineFingerprint(deps: MachineFingerprintDeps = {}): st
}); });
const override = env.DND_LICENSE_DEVICE_ID?.trim(); const override = env.DND_LICENSE_DEVICE_ID?.trim();
if (override && override.length >= 8) return override; if (override && override.length >= 8) {
memoryFingerprint = override;
return override;
}
if (memoryFingerprint && isPlausiblyFingerprint(memoryFingerprint)) {
return memoryFingerprint;
}
const userData = deps.userData?.trim();
const cacheFile = userData ? machineFingerprintCachePath(userData) : null;
if (cacheFile) {
const fromDisk = readDiskFingerprintCache(cacheFile, readFile, existsSync);
if (fromDisk) {
memoryFingerprint = fromDisk;
return fromDisk;
}
}
let raw: string | null = null; let raw: string | null = null;
if (platform === 'win32') raw = readWindowsRawId(exec); if (platform === 'win32') raw = readWindowsRawId(exec);
@@ -189,5 +251,10 @@ export function resolveMachineFingerprint(deps: MachineFingerprintDeps = {}): st
); );
} }
return hashMachineRawId(platform, raw); const fingerprint = hashMachineRawId(platform, raw);
memoryFingerprint = fingerprint;
if (cacheFile) {
writeDiskFingerprintCache(cacheFile, fingerprint, mkdirSync, writeFileSync);
}
return fingerprint;
} }
+5
View File
@@ -14,6 +14,11 @@ export function deviceIdPath(userData: string): string {
return path.join(userData, 'device.id'); return path.join(userData, 'device.id');
} }
/** Кэш hashed machine fingerprint — без повторного reg/wmic/ioreg на каждом старте. */
export function machineFingerprintCachePath(userData: string): string {
return path.join(userData, 'machine.fingerprint');
}
export function preferencesPath(userData: string): string { export function preferencesPath(userData: string): string {
return path.join(userData, 'preferences.json'); return path.join(userData, 'preferences.json');
} }
+16
View File
@@ -0,0 +1,16 @@
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));
void test('main: EPIPE guards ставятся до app.whenReady', () => {
const index = fs.readFileSync(path.join(here, 'index.ts'), 'utf8');
const safe = fs.readFileSync(path.join(here, 'safeConsole.ts'), 'utf8');
assert.ok(index.includes('installStdoutEpipeGuards'));
assert.ok(index.indexOf('installStdoutEpipeGuards()') < index.indexOf('app.requestSingleInstanceLock'));
assert.ok(safe.includes('EPIPE'));
assert.ok(safe.includes('safeConsoleError'));
});
+26
View File
@@ -0,0 +1,26 @@
/**
* В Electron (особенно после рестарта в dev) stdout/stderr часто уже закрыты.
* Обычный `console.error` тогда даёт EPIPE и валит main process диалогом Uncaught Exception.
*/
function isBrokenPipe(err: unknown): boolean {
const code = (err as NodeJS.ErrnoException | undefined)?.code;
return code === 'EPIPE' || code === 'ERR_STREAM_DESTROYED';
}
export function installStdoutEpipeGuards(): void {
for (const stream of [process.stdout, process.stderr]) {
stream?.on('error', (err: NodeJS.ErrnoException) => {
if (isBrokenPipe(err)) return;
});
}
}
export function safeConsoleError(...args: unknown[]): void {
try {
console.error(...args);
} catch (err) {
if (isBrokenPipe(err)) return;
throw err;
}
}
@@ -73,3 +73,10 @@ void test('createWindows: показ окна — не только ready-to-sho
assert.ok(src.includes('ensureWindowBecomesVisible')); assert.ok(src.includes('ensureWindowBecomesVisible'));
assert.ok(src.includes('did-finish-load')); assert.ok(src.includes('did-finish-load'));
}); });
void test('createWindows: логи окон не валят main через EPIPE', () => {
const src = readCreateWindows();
assert.ok(src.includes('safeConsoleError'));
assert.ok(src.includes('errorCode === -3'));
assert.ok(src.includes('isMainFrame'));
});
+9 -4
View File
@@ -5,6 +5,8 @@ import { app, BrowserWindow, screen } from 'electron';
import { windowChromeTitle } from '../../shared/appBranding'; import { windowChromeTitle } from '../../shared/appBranding';
import { ipcChannels } from '../../shared/ipc/contracts'; import { ipcChannels } from '../../shared/ipc/contracts';
import { safeConsoleError } from '../safeConsole';
import { getBootSplashWindow } from './bootWindow'; import { getBootSplashWindow } from './bootWindow';
import { loadBrandingWindowIcon } from './brandingIcon'; import { loadBrandingWindowIcon } from './brandingIcon';
@@ -245,13 +247,16 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
} }
win.webContents.on('preload-error', (_event, preloadPath, error) => { win.webContents.on('preload-error', (_event, preloadPath, error) => {
console.error(`[preload-error] ${preloadPath}:`, error); safeConsoleError(`[preload-error] ${preloadPath}:`, error);
}); });
win.webContents.on('did-fail-load', (_event, errorCode, errorDescription, validatedURL) => { win.webContents.on('did-fail-load', (_event, errorCode, errorDescription, validatedURL, isMainFrame) => {
console.error(`[did-fail-load] ${String(errorCode)} ${errorDescription} ${validatedURL}`); // -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) => { win.webContents.on('render-process-gone', (_event, details) => {
console.error('[render-process-gone]', details.reason, details.exitCode); safeConsoleError('[render-process-gone]', details.reason, details.exitCode);
}); });
if (!deferEditor) { if (!deferEditor) {
+48 -1
View File
@@ -4,14 +4,20 @@
display: grid; display: grid;
grid-template-columns: 280px 1fr; grid-template-columns: 280px 1fr;
gap: 16px; gap: 16px;
overflow: auto;
min-height: 0;
box-sizing: border-box;
} }
.remote { .remote {
padding: 12px; padding: 12px;
height: 100%; height: 100%;
min-height: 0; min-height: calc(100vh - 32px);
min-width: 0;
overflow: hidden;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
box-sizing: border-box;
} }
.remoteTitle { .remoteTitle {
@@ -456,10 +462,51 @@
white-space: nowrap; white-space: nowrap;
} }
.audioControls {
display: flex;
flex-direction: column;
gap: 8px;
flex-shrink: 0;
align-items: stretch;
min-width: 132px;
}
.audioTransport { .audioTransport {
display: flex; display: flex;
gap: 10px; gap: 10px;
flex-shrink: 0; flex-shrink: 0;
justify-content: flex-end;
}
.audioVolumeRow {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.audioVolumeIcon {
flex-shrink: 0;
width: 16px;
height: 16px;
color: var(--text2);
display: flex;
align-items: center;
justify-content: center;
}
.audioVolumeIcon svg {
display: block;
width: 16px;
height: 16px;
}
.audioVolume {
flex: 1;
min-width: 0;
width: 100%;
margin: 0;
accent-color: var(--accent-fill-solid);
} }
.scrubFill { .scrubFill {
File diff suppressed because it is too large Load Diff
+304
View File
@@ -0,0 +1,304 @@
import React, { useEffect, useRef, useState } from 'react';
import { Button } from '../shared/ui/controls';
import styles from './ControlApp.module.css';
function formatTime(sec: number): string {
if (!Number.isFinite(sec) || sec < 0) return '0:00';
const s = Math.floor(sec);
const m = Math.floor(s / 60);
const r = s % 60;
return `${String(m)}:${String(r).padStart(2, '0')}`;
}
function clampAudioGain(v: number): number {
if (!Number.isFinite(v)) return 1;
return Math.max(0, Math.min(1, v));
}
function VolumeSpeakerIcon({ gain }: { gain: number }) {
if (gain <= 0.001) {
return (
<svg viewBox="0 0 24 24" aria-hidden focusable="false">
<path
fill="currentColor"
d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3 3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4 9.91 6.09 12 8.18V4z"
/>
</svg>
);
}
if (gain < 0.5) {
return (
<svg viewBox="0 0 24 24" aria-hidden focusable="false">
<path
fill="currentColor"
d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z"
/>
</svg>
);
}
return (
<svg viewBox="0 0 24 24" aria-hidden focusable="false">
<path
fill="currentColor"
d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"
/>
</svg>
);
}
export type ControlAudioCardProps = {
assetId: string;
name: string;
autoplay: boolean;
loop: boolean;
statusLabel: string;
statusDetail?: string;
extraBadge?: React.ReactNode;
audioEl: HTMLAudioElement | null;
initialGain: number;
gainMap: Map<string, number>;
playTitle: string;
playLabel: string;
pauseLabel: string;
stopLabel: string;
volumeLabel: string;
modeAutoLabel: string;
modeManualLabel: string;
loopLabel: string;
onceLabel: string;
scrubSeekLabel: string;
durationUnknownLabel: string;
/** Редкий bump родителя (play/pause/error) — не для scrub. */
onStatusChange: () => void;
onPlay: () => void;
onPause: () => void;
onStop: () => void;
};
/**
* Карточка трека: scrub/time обновляются локально (RAF → DOM), без ре-рендера всего ControlApp.
*/
export function ControlAudioCard({
assetId,
name,
autoplay,
loop,
statusLabel,
statusDetail,
extraBadge,
audioEl,
initialGain,
gainMap,
playTitle,
playLabel,
pauseLabel,
stopLabel,
volumeLabel,
modeAutoLabel,
modeManualLabel,
loopLabel,
onceLabel,
scrubSeekLabel,
durationUnknownLabel,
onStatusChange,
onPlay,
onPause,
onStop,
}: ControlAudioCardProps) {
const scrubRef = useRef<HTMLDivElement | null>(null);
const scrubFillRef = useRef<HTMLDivElement | null>(null);
const curTimeRef = useRef<HTMLDivElement | null>(null);
const durTimeRef = useRef<HTMLDivElement | null>(null);
const [gainUi, setGainUi] = useState(() => clampAudioGain(initialGain));
const onStatusChangeRef = useRef(onStatusChange);
onStatusChangeRef.current = onStatusChange;
useEffect(() => {
setGainUi(clampAudioGain(gainMap.get(assetId) ?? initialGain));
}, [assetId, gainMap, initialGain]);
useEffect(() => {
if (!audioEl) return;
let raf = 0;
const paint = (): void => {
const dur = audioEl.duration && Number.isFinite(audioEl.duration) ? audioEl.duration : 0;
const cur = audioEl.currentTime && Number.isFinite(audioEl.currentTime) ? audioEl.currentTime : 0;
const pct = dur > 0 ? Math.max(0, Math.min(1, cur / dur)) : 0;
if (scrubFillRef.current) {
scrubFillRef.current.style.width = `${String(Math.round(pct * 100))}%`;
}
if (curTimeRef.current) curTimeRef.current.textContent = formatTime(cur);
if (durTimeRef.current) durTimeRef.current.textContent = dur ? formatTime(dur) : '—:—';
if (scrubRef.current) {
scrubRef.current.setAttribute('aria-valuemin', '0');
scrubRef.current.setAttribute('aria-valuemax', String(dur > 0 ? Math.round(dur) : 0));
scrubRef.current.setAttribute('aria-valuenow', String(Math.round(cur)));
scrubRef.current.title = dur > 0 ? scrubSeekLabel : durationUnknownLabel;
scrubRef.current.classList.toggle(styles.audioScrubPointer ?? 'audioScrubPointer', dur > 0);
scrubRef.current.classList.toggle(styles.audioScrubDefault ?? 'audioScrubDefault', dur <= 0);
}
};
const stopLoop = (): void => {
if (raf !== 0) {
window.cancelAnimationFrame(raf);
raf = 0;
}
};
const loopPaint = (): void => {
paint();
if (!audioEl.paused) {
raf = window.requestAnimationFrame(loopPaint);
} else {
raf = 0;
}
};
const startLoop = (): void => {
stopLoop();
raf = window.requestAnimationFrame(loopPaint);
};
const onPlayEv = (): void => {
startLoop();
onStatusChangeRef.current();
};
const onPauseEv = (): void => {
stopLoop();
paint();
onStatusChangeRef.current();
};
const onEndedEv = (): void => {
stopLoop();
paint();
onStatusChangeRef.current();
};
const onMetaEv = (): void => {
paint();
onStatusChangeRef.current();
};
audioEl.addEventListener('play', onPlayEv);
audioEl.addEventListener('pause', onPauseEv);
audioEl.addEventListener('ended', onEndedEv);
audioEl.addEventListener('canplay', onMetaEv);
audioEl.addEventListener('error', onMetaEv);
paint();
if (!audioEl.paused) startLoop();
return () => {
stopLoop();
audioEl.removeEventListener('play', onPlayEv);
audioEl.removeEventListener('pause', onPauseEv);
audioEl.removeEventListener('ended', onEndedEv);
audioEl.removeEventListener('canplay', onMetaEv);
audioEl.removeEventListener('error', onMetaEv);
};
}, [audioEl, durationUnknownLabel, scrubSeekLabel]);
const seekByClientX = (clientX: number): void => {
if (!audioEl || !scrubRef.current) return;
const dur = audioEl.duration && Number.isFinite(audioEl.duration) ? audioEl.duration : 0;
if (!dur) return;
const rect = scrubRef.current.getBoundingClientRect();
const next = (clientX - rect.left) / Math.max(1, rect.width);
audioEl.currentTime = Math.max(0, Math.min(dur, next * dur));
const cur = audioEl.currentTime;
const pct = Math.max(0, Math.min(1, cur / dur));
if (scrubFillRef.current) scrubFillRef.current.style.width = `${String(Math.round(pct * 100))}%`;
if (curTimeRef.current) curTimeRef.current.textContent = formatTime(cur);
};
const applyGain = (v: number): void => {
const g = clampAudioGain(v);
gainMap.set(assetId, g);
if (audioEl) {
try {
audioEl.volume = g;
} catch {
// ignore
}
}
setGainUi(g);
};
return (
<div className={styles.audioCard}>
<div className={styles.audioMeta}>
<div className={styles.audioName}>{name}</div>
<div className={styles.audioBadges}>
<div>{autoplay ? modeAutoLabel : modeManualLabel}</div>
<div>{loop ? loopLabel : onceLabel}</div>
<div title={statusDetail}>{statusLabel}</div>
{extraBadge}
</div>
<div className={styles.spacer10} />
<div
ref={scrubRef}
role="slider"
tabIndex={0}
className={[styles.audioScrub, styles.audioScrubDefault].join(' ')}
onKeyDown={(e) => {
if (!audioEl) return;
const dur = audioEl.duration && Number.isFinite(audioEl.duration) ? audioEl.duration : 0;
if (!dur) return;
if (e.key === 'ArrowLeft') audioEl.currentTime = Math.max(0, audioEl.currentTime - 5);
if (e.key === 'ArrowRight') audioEl.currentTime = Math.min(dur, audioEl.currentTime + 5);
if (curTimeRef.current) curTimeRef.current.textContent = formatTime(audioEl.currentTime);
const pct = Math.max(0, Math.min(1, audioEl.currentTime / dur));
if (scrubFillRef.current) scrubFillRef.current.style.width = `${String(Math.round(pct * 100))}%`;
}}
onClick={(e) => seekByClientX(e.clientX)}
>
<div ref={scrubFillRef} className={styles.scrubFill} style={{ width: '0%' }} />
</div>
<div className={styles.timeRow}>
<div ref={curTimeRef}>0:00</div>
<div ref={durTimeRef}>:</div>
</div>
</div>
<div className={styles.audioControls}>
<div className={styles.audioTransport}>
<Button variant="primary" title={playTitle} ariaLabel={playLabel} onClick={onPlay}>
</Button>
<Button title={pauseLabel} ariaLabel={pauseLabel} onClick={onPause}>
</Button>
<Button
title={stopLabel}
ariaLabel={stopLabel}
onClick={() => {
onStop();
if (scrubFillRef.current) scrubFillRef.current.style.width = '0%';
if (curTimeRef.current) curTimeRef.current.textContent = '0:00';
}}
>
</Button>
</div>
<div className={styles.audioVolumeRow}>
<span className={styles.audioVolumeIcon} aria-hidden>
<VolumeSpeakerIcon gain={gainUi} />
</span>
<input
type="range"
min={0}
max={1}
step={0.01}
value={gainUi}
disabled={!audioEl}
className={styles.audioVolume}
aria-label={volumeLabel}
title={`${volumeLabel}: ${String(Math.round(gainUi * 100))}%`}
onChange={(e) => applyGain(Number(e.currentTarget.value))}
/>
</div>
</div>
</div>
);
}
@@ -0,0 +1,27 @@
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));
/** Регресс: RAF в ControlApp бампил оба audio-tick на каждом кадре → полный ре-рендер пульта. */
void test('ControlApp: нет per-frame RAF setState для аудио scrub', () => {
const app = fs.readFileSync(path.join(here, 'ControlApp.tsx'), 'utf8');
const card = fs.readFileSync(path.join(here, 'ControlAudioCard.tsx'), 'utf8');
assert.doesNotMatch(app, /\banyPlaying\b/);
// Старый паттерн: RAF tick → оба set*AudioStateTick.
assert.doesNotMatch(
app,
/const tick = \(\) => \{\s*setSceneAudioStateTick/,
'корневой RAF-тик аудио удалён',
);
assert.doesNotMatch(app, /requestAnimationFrame\s*\(\s*tick\s*\)/);
assert.ok(app.includes('ControlAudioCard'));
assert.ok(card.includes('requestAnimationFrame'), 'scrub крутится локально в карточке');
assert.ok(card.includes('scrubFillRef'), 'прогресс пишется в DOM, не через setState корня');
assert.ok(card.includes('setGainUi'), 'громкость обновляет только карточку');
});
@@ -0,0 +1,35 @@
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));
/** Регресс: scheduleDraftRepaint бампил draftFxTick → полный ре-рендер ControlApp на кадр штриха. */
void test('ControlApp: draft кисти без корневого draftFxTick', () => {
const app = fs.readFileSync(path.join(here, 'ControlApp.tsx'), 'utf8');
const pixi = fs.readFileSync(
path.join(here, '../shared/effects/PxiEffectsOverlay.tsx'),
'utf8',
);
assert.doesNotMatch(app, /\bdraftFxTick\b/);
assert.doesNotMatch(app, /\bsetDraftFxTick\b/);
assert.doesNotMatch(app, /\bfxMergedState\b/);
assert.ok(app.includes('scheduleDraftRepaint'));
assert.ok(app.includes('pushDraftToPixi'));
assert.ok(app.includes('effectsOverlayRef'));
assert.match(app, /scheduleDraftRepaint[\s\S]*?pushDraftToPixi\(\)/);
assert.doesNotMatch(
app,
/scheduleDraftRepaint[\s\S]*?setDraftFxTick/,
'RAF draft не трогает React state корня',
);
assert.ok(pixi.includes('PixiEffectsOverlayHandle'));
assert.ok(pixi.includes('setDraft'));
assert.ok(pixi.includes('mergeDraftState'));
assert.ok(pixi.includes('forwardRef'));
});
@@ -152,6 +152,31 @@ void test('ControlApp: музыка разделена на сцену и кам
assert.match(src, /pause campaign\./i); assert.match(src, /pause campaign\./i);
}); });
void test('ControlApp: у каждой аудиозаписи есть регулятор громкости под транспортом', () => {
const src = readControlApp();
const card = fs.readFileSync(path.join(here, 'ControlAudioCard.tsx'), 'utf8');
const css = readControlAppCss();
assert.ok(src.includes('ControlAudioCard'));
assert.ok(src.includes("t('control.volume')"));
assert.ok(src.includes('sceneAudioGainRef'));
assert.ok(src.includes('campaignAudioGainRef'));
assert.ok(src.includes('applyAudioGain'));
assert.ok(card.includes('VolumeSpeakerIcon'));
assert.ok(card.includes('styles.audioVolume'));
assert.ok(card.includes('styles.audioVolumeRow'));
assert.match(css, /\.audioControls[\s\S]*?flex-direction:\s*column/);
assert.match(css, /\.audioVolumeRow\b/);
assert.match(css, /\.audioVolumeIcon\b/);
assert.match(css, /\.audioVolume\b/);
});
void test('ControlApp: весь контент скроллится в окне, отступы сверху и снизу равны', () => {
const css = readControlAppCss();
assert.match(css, /\.page\s*\{[^}]*padding:\s*16px/s);
assert.match(css, /\.page\s*\{[^}]*overflow:\s*auto/s);
assert.doesNotMatch(css, /\.rightStack\s*\{[^}]*overflow-y:\s*auto/s);
});
void test('ControlApp: загрузка камп. аудио — useEffect зависит только от api и campaignAudioSpecKey', () => { void test('ControlApp: загрузка камп. аудио — useEffect зависит только от api и campaignAudioSpecKey', () => {
const src = readControlApp(); const src = readControlApp();
const re = /\/\/ Campaign elements:[\s\S]*?useEffect\(\(\) => \{[\s\S]*?\}\s*,\s*\[([^\]]*)\]\s*\)\s*;/; const re = /\/\/ Campaign elements:[\s\S]*?useEffect\(\(\) => \{[\s\S]*?\}\s*,\s*\[([^\]]*)\]\s*\)\s*;/;
@@ -588,6 +588,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'control.transportPlay': 'Воспроизведение', 'control.transportPlay': 'Воспроизведение',
'control.transportPause': 'Пауза', 'control.transportPause': 'Пауза',
'control.transportStop': 'Стоп', 'control.transportStop': 'Стоп',
'control.volume': 'Громкость',
}, },
en: { en: {
'common.close': 'Close', 'common.close': 'Close',
@@ -1125,6 +1126,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'control.transportPlay': 'Play', 'control.transportPlay': 'Play',
'control.transportPause': 'Pause', 'control.transportPause': 'Pause',
'control.transportStop': 'Stop', 'control.transportStop': 'Stop',
'control.volume': 'Volume',
}, },
}; };
@@ -19,6 +19,7 @@ import type {
SceneId, SceneId,
} from '../../../shared/types'; } from '../../../shared/types';
import { getDndApi } from '../../shared/dndApi'; import { getDndApi } from '../../shared/dndApi';
import { invalidateAssetUrlCache } from '../../shared/useAssetImageUrl';
type ProjectSummary = { id: ProjectId; name: string; updatedAt: string; fileName: string }; type ProjectSummary = { id: ProjectId; name: string; updatedAt: string; fileName: string };
@@ -277,6 +278,8 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
projectDataEpochRef.current += 1; projectDataEpochRef.current += 1;
const epoch = projectDataEpochRef.current; const epoch = projectDataEpochRef.current;
openInFlightRef.current = null; openInFlightRef.current = null;
// URL ассетов зависят от открытого проекта — сбрасываем renderer-кэш.
invalidateAssetUrlCache();
const job = (async () => { const job = (async () => {
setState((s) => ({ ...s, openingProjectId: id })); setState((s) => ({ ...s, openingProjectId: id }));
@@ -308,6 +311,7 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
const closeProject = async () => { const closeProject = async () => {
projectDataEpochRef.current += 1; projectDataEpochRef.current += 1;
openInFlightRef.current = null; openInFlightRef.current = null;
invalidateAssetUrlCache();
try { try {
await api.invoke(ipcChannels.project.close, {}); await api.invoke(ipcChannels.project.close, {});
} finally { } finally {
+11 -7
View File
@@ -12,6 +12,7 @@ import { MaterialOverlay } from './materials/MaterialOverlay';
import { useMaterialsOverlayState } from './materials/useMaterialsOverlayState'; import { useMaterialsOverlayState } from './materials/useMaterialsOverlayState';
import { NpcsSceneOverlay } from './npcs/NpcsSceneOverlay'; import { NpcsSceneOverlay } from './npcs/NpcsSceneOverlay';
import { useNpcsOverlayState } from './npcs/useNpcsOverlayState'; import { useNpcsOverlayState } from './npcs/useNpcsOverlayState';
import { SceneOverlayHost } from './sceneOverlay/SceneOverlayHost';
import styles from './PresentationView.module.css'; import styles from './PresentationView.module.css';
import { RotatedImage } from './RotatedImage'; import { RotatedImage } from './RotatedImage';
import { useAssetUrl } from './useAssetImageUrl'; import { useAssetUrl } from './useAssetImageUrl';
@@ -164,13 +165,16 @@ export function PresentationView({
{showEffects && scene?.previewAssetType === 'image' && scene.darkenScene && contentRect ? ( {showEffects && scene?.previewAssetType === 'image' && scene.darkenScene && contentRect ? (
<SceneDarknessOverlay state={sdState} overlayAlpha={1} viewport={contentRect} /> <SceneDarknessOverlay state={sdState} overlayAlpha={1} viewport={contentRect} />
) : null} ) : null}
{activeMaterial ? ( <SceneOverlayHost active={Boolean(activeMaterial) || activeNpcItems.length > 0}>
<MaterialOverlay {activeMaterial ? (
assetId={activeMaterial.assetId} <MaterialOverlay
layout={materialsOverlay?.layout ?? DEFAULT_MATERIALS_OVERLAY_LAYOUT} embedded
/> assetId={activeMaterial.assetId}
) : null} layout={materialsOverlay?.layout ?? DEFAULT_MATERIALS_OVERLAY_LAYOUT}
{activeNpcItems.length > 0 ? <NpcsSceneOverlay items={activeNpcItems} /> : null} />
) : null}
{activeNpcItems.length > 0 ? <NpcsSceneOverlay embedded items={activeNpcItems} /> : null}
</SceneOverlayHost>
{showTitle ? ( {showTitle ? (
<div className={styles.titleWrap}> <div className={styles.titleWrap}>
<div className={compact ? styles.titleCompact : styles.titleFull}> <div className={compact ? styles.titleCompact : styles.titleFull}>
@@ -15,3 +15,24 @@ void test('PxiEffectsOverlay: ограничение FPS тикера для н
const src = fs.readFileSync(path.join(here, 'PxiEffectsOverlay.tsx'), 'utf8'); const src = fs.readFileSync(path.join(here, 'PxiEffectsOverlay.tsx'), 'utf8');
assert.ok(src.includes('app.ticker.maxFPS')); assert.ok(src.includes('app.ticker.maxFPS'));
}); });
void test('PxiEffectsOverlay: imperative setDraft для кисти без React state', () => {
const src = fs.readFileSync(path.join(here, 'PxiEffectsOverlay.tsx'), 'utf8');
assert.ok(src.includes('setDraft'));
assert.ok(src.includes('useImperativeHandle'));
assert.ok(src.includes('mergeDraftState'));
});
void test('PxiEffectsOverlay: lazy VFX packs + idle ticker stop', () => {
const src = fs.readFileSync(path.join(here, 'PxiEffectsOverlay.tsx'), 'utf8');
assert.ok(src.includes('ensureVfxPacksForState'));
assert.ok(src.includes('collectNeededVfxPacks'));
assert.ok(src.includes('syncTickerForState'));
assert.ok(src.includes('app.ticker.stop'));
// Eager preload всех наборов при init убран.
assert.doesNotMatch(
src,
/syncNodes\([\s\S]*?stateRef\.current[\s\S]*?\);\s*void preloadLightningVfxFrameTextures\(pixi\);\s*void preloadElectricAccentFrameTextures\(pixi\);\s*void preloadFogVfxFrameTextures\(pixi\);/,
);
assert.ok(src.includes('Lazy VFX'));
});
+221 -27
View File
@@ -1,9 +1,140 @@
import React, { useEffect, useMemo, useRef } from 'react'; import React, { forwardRef, useEffect, useImperativeHandle, useMemo, useRef } from 'react';
import type { EffectsState, EffectInstance } from '../../../shared/types/effects'; import type {
EffectInstance,
EffectInstanceType,
EffectsState,
EffectToolType,
} from '../../../shared/types/effects';
import styles from './PxiEffectsOverlay.module.css'; import styles from './PxiEffectsOverlay.module.css';
export type PixiEffectsOverlayHandle = {
/** Draft-штрих без React re-render родителя; null — сброс. */
setDraft: (instance: EffectInstance | null) => void;
};
/** Наборы кадровых VFX — подгружаем только по tool / живым instances. */
type VfxFramePack =
| 'fog'
| 'fire'
| 'rain'
| 'water'
| 'lightning'
| 'sunbeam'
| 'poisonCloud';
function mergeDraftState(
state: EffectsState | null,
draft: EffectInstance | null,
): EffectsState | null {
if (!draft) return state;
if (!state) {
return {
revision: 0,
serverNowMs: Date.now(),
tool: { tool: 'fog', radiusN: 0.05, intensity: 1 },
instances: [draft],
};
}
const rest = state.instances.filter((i) => i.id !== '__draft__');
return { ...state, instances: [...rest, draft] };
}
function vfxPacksForTool(tool: EffectToolType): readonly VfxFramePack[] {
switch (tool) {
case 'fog':
return ['fog'];
case 'fire':
return ['fire'];
case 'rain':
return ['rain'];
case 'water':
return ['water'];
case 'lightning':
return ['lightning'];
case 'sunbeam':
return ['sunbeam'];
case 'poisonCloud':
return ['poisonCloud'];
default:
return [];
}
}
function vfxPacksForInstanceType(type: EffectInstanceType): readonly VfxFramePack[] {
switch (type) {
case 'fog':
return ['fog'];
case 'fire':
return ['fire'];
case 'rain':
return ['rain'];
case 'water':
return ['water'];
case 'lightning':
return ['lightning'];
case 'sunbeam':
return ['sunbeam'];
case 'poisonCloud':
return ['poisonCloud'];
default:
return [];
}
}
function collectNeededVfxPacks(state: EffectsState | null): VfxFramePack[] {
if (!state) return [];
const packs = new Set<VfxFramePack>();
for (const p of vfxPacksForTool(state.tool.tool)) packs.add(p);
for (const inst of state.instances) {
for (const p of vfxPacksForInstanceType(inst.type)) packs.add(p);
}
return [...packs];
}
function preloadVfxFramePack(pixi: any, pack: VfxFramePack): void {
switch (pack) {
case 'fog':
void preloadFogVfxFrameTextures(pixi);
break;
case 'fire':
void preloadGroundFireVfxFrameTextures(pixi);
break;
case 'rain':
void preloadRainVfxFrameTextures(pixi);
break;
case 'water':
void preloadWaterVfxFrameTextures(pixi);
break;
case 'lightning':
void preloadLightningVfxFrameTextures(pixi);
void preloadElectricAccentFrameTextures(pixi);
break;
case 'sunbeam':
void preloadPulseDischargeFrameTextures(pixi);
break;
case 'poisonCloud':
void preloadDustBurstFrameTextures(pixi);
break;
default: {
const _exhaustive: never = pack;
void _exhaustive;
}
}
}
function ensureVfxPacksForState(pixi: any, state: EffectsState | null): void {
if (!pixi) return;
for (const pack of collectNeededVfxPacks(state)) {
preloadVfxFramePack(pixi, pack);
}
}
function effectsHaveWork(state: EffectsState | null): boolean {
return Boolean(state && state.instances.length > 0);
}
const LIGHTNING_VFX_FRAME_COUNT = 19; const LIGHTNING_VFX_FRAME_COUNT = 19;
const LIGHTNING_VFX_FRAME_ASPECT = 420 / 473; const LIGHTNING_VFX_FRAME_ASPECT = 420 / 473;
const LIGHTNING_VFX_STRIKE_MS = 320; const LIGHTNING_VFX_STRIKE_MS = 320;
@@ -52,18 +183,81 @@ type Props = {
* - Pixi `Application` — это WebGL-рендерер + тикер. * - Pixi `Application` — это WebGL-рендерер + тикер.
* - Мы держим один `Application` на компонент, и при изменении `state` просто перерисовываем сцену. * - Мы держим один `Application` на компонент, и при изменении `state` просто перерисовываем сцену.
* - Вариант A: рисуем "инстансы эффектов" (данные), а не пиксели. * - Вариант A: рисуем "инстансы эффектов" (данные), а не пиксели.
* - Draft кисти идёт через `setDraft` (imperative), без re-render ControlApp.
*/ */
export function PixiEffectsOverlay({ state, interactive = false, style, viewport }: Props) { export const PixiEffectsOverlay = forwardRef<PixiEffectsOverlayHandle, Props>(function PixiEffectsOverlay(
{ state, interactive = false, style, viewport },
ref,
) {
const hostRef = useRef<HTMLDivElement | null>(null); const hostRef = useRef<HTMLDivElement | null>(null);
const appRef = useRef<any>(null); const appRef = useRef<any>(null);
const rootRef = useRef<any>(null); const rootRef = useRef<any>(null);
const pixiRef = useRef<any>(null); const pixiRef = useRef<any>(null);
const nodesRef = useRef<Map<string, any>>(new Map()); const nodesRef = useRef<Map<string, any>>(new Map());
const committedStateRef = useRef<EffectsState | null>(null);
const draftRef = useRef<EffectInstance | null>(null);
const stateRef = useRef<EffectsState | null>(null); const stateRef = useRef<EffectsState | null>(null);
const timeOffsetRef = useRef(0); const timeOffsetRef = useRef(0);
const sizeRef = useRef<{ w: number; h: number }>({ w: 1, h: 1 }); const sizeRef = useRef<{ w: number; h: number }>({ w: 1, h: 1 });
const viewportRef = useRef<{ x: number; y: number; w: number; h: number }>({ x: 0, y: 0, w: 1, h: 1 }); const viewportRef = useRef<{ x: number; y: number; w: number; h: number }>({ x: 0, y: 0, w: 1, h: 1 });
const viewportProvidedRef = useRef(false); const viewportProvidedRef = useRef(false);
/** null = ещё не синхронизировали с Pixi (ticker по умолчанию бежит). */
const tickerWantedRef = useRef<boolean | null>(null);
const syncTickerForState = (merged: EffectsState | null): void => {
const app = appRef.current;
if (!app?.ticker) return;
const want = effectsHaveWork(merged);
if (tickerWantedRef.current === want) return;
tickerWantedRef.current = want;
if (want) {
try {
app.ticker.start();
} catch {
/* ignore */
}
return;
}
const root = rootRef.current;
if (root) {
root.x = 0;
root.y = 0;
}
try {
app.ticker.stop();
} catch {
/* ignore */
}
};
const applyMergedState = (committed: EffectsState | null, draft: EffectInstance | null): void => {
committedStateRef.current = committed;
draftRef.current = draft;
const merged = mergeDraftState(committed, draft);
stateRef.current = merged;
if (merged) {
timeOffsetRef.current = merged.serverNowMs - Date.now();
}
const pixi = pixiRef.current;
const root = rootRef.current;
if (!pixi || !root) {
syncTickerForState(merged);
return;
}
ensureVfxPacksForState(pixi, merged);
syncNodes(pixi, root, nodesRef.current, merged, sizeRef.current, viewportRef.current);
syncTickerForState(merged);
};
useImperativeHandle(
ref,
() => ({
setDraft: (instance) => {
applyMergedState(committedStateRef.current, instance);
},
}),
[],
);
/** Снижаем resolution на HiDPI — меньше пикселей в WebGL, визуально ок для оверлея эффектов. */ /** Снижаем resolution на HiDPI — меньше пикселей в WebGL, визуально ок для оверлея эффектов. */
const dpr = useMemo(() => Math.min(1.5, window.devicePixelRatio || 1), []); const dpr = useMemo(() => Math.min(1.5, window.devicePixelRatio || 1), []);
@@ -121,32 +315,28 @@ export function PixiEffectsOverlay({ state, interactive = false, style, viewport
if (!viewportProvidedRef.current) { if (!viewportProvidedRef.current) {
viewportRef.current = { x: 0, y: 0, w: sizeRef.current.w, h: sizeRef.current.h }; viewportRef.current = { x: 0, y: 0, w: sizeRef.current.w, h: sizeRef.current.h };
} }
// Lazy VFX: только pack'и для текущего tool / уже размещённых instances (не все наборы сразу).
ensureVfxPacksForState(pixi, stateRef.current);
syncNodes(pixi, root, nodesRef.current, stateRef.current, sizeRef.current, viewportRef.current); syncNodes(pixi, root, nodesRef.current, stateRef.current, sizeRef.current, viewportRef.current);
void preloadLightningVfxFrameTextures(pixi);
void preloadElectricAccentFrameTextures(pixi);
void preloadFogVfxFrameTextures(pixi);
void preloadGroundFireVfxFrameTextures(pixi);
void preloadRainVfxFrameTextures(pixi);
void preloadWaterVfxFrameTextures(pixi);
void preloadPulseDischargeFrameTextures(pixi);
void preloadDustBurstFrameTextures(pixi);
// Animation loop: на каждом кадре обновляем свойства инстансов (alpha/дрейф/фликер). // Animation loop: на каждом кадре обновляем свойства инстансов (alpha/дрейф/фликер).
// В idle (нет instances/draft) ticker останавливается — см. syncTickerForState.
app.ticker.add(() => { app.ticker.add(() => {
const s = stateRef.current; const s = stateRef.current;
if (!s) return; if (!s || s.instances.length === 0) return;
const nowMs = Date.now() + timeOffsetRef.current; const nowMs = Date.now() + timeOffsetRef.current;
animateNodes(pixi, nodesRef.current, s, nowMs, sizeRef.current, viewportRef.current); animateNodes(pixi, nodesRef.current, s, nowMs, sizeRef.current, viewportRef.current);
// Лёгкое “потряхивание” сцены в момент удара молнии. // Лёгкое “потряхивание” сцены в момент удара молнии.
// Делаем через смещение корневого контейнера, чтобы не вмешиваться в рендерер/камера-логику. // Делаем через смещение корневого контейнера, чтобы не вмешиваться в рендерер/камера-логику.
const root = rootRef.current; const rootNode = rootRef.current;
if (root) { if (rootNode) {
const { x, y } = computeSceneShake(s, nowMs, sizeRef.current); const { x, y } = computeSceneShake(s, nowMs, sizeRef.current);
root.x = x; rootNode.x = x;
root.y = y; rootNode.y = y;
} }
}); });
syncTickerForState(stateRef.current);
cleanup = () => ro.disconnect(); cleanup = () => ro.disconnect();
} catch (e) { } catch (e) {
@@ -181,16 +371,7 @@ export function PixiEffectsOverlay({ state, interactive = false, style, viewport
}, [interactive]); }, [interactive]);
useEffect(() => { useEffect(() => {
const app = appRef.current; applyMergedState(state, draftRef.current);
const root = rootRef.current;
if (!app || !root) return;
stateRef.current = state;
if (state) {
timeOffsetRef.current = state.serverNowMs - Date.now();
}
const pixi = pixiRef.current;
if (!pixi) return;
syncNodes(pixi, root, nodesRef.current, state, sizeRef.current, viewportRef.current);
}, [state]); }, [state]);
useEffect(() => { useEffect(() => {
@@ -207,7 +388,7 @@ export function PixiEffectsOverlay({ state, interactive = false, style, viewport
const hostClass = [styles.host, interactive ? styles.hostInteractive : styles.hostPassthrough].join(' '); const hostClass = [styles.host, interactive ? styles.hostInteractive : styles.hostPassthrough].join(' ');
return <div ref={hostRef} className={hostClass} style={style} />; return <div ref={hostRef} className={hostClass} style={style} />;
} });
function syncNodes( function syncNodes(
pixi: any, pixi: any,
@@ -236,6 +417,19 @@ function syncNodes(
const sig = instanceSig(inst, viewport); const sig = instanceSig(inst, viewport);
const existing = nodes.get(inst.id); const existing = nodes.get(inst.id);
if (existing && (existing as any).__sig === sig) continue; if (existing && (existing as any).__sig === sig) continue;
// Water draft: перерисовываем Graphics in-place (без destroy/create на каждую точку).
if (
existing &&
inst.id === '__draft__' &&
inst.type === 'water' &&
(existing as any).__fx?.kind === 'waterDraft'
) {
const halfW = Math.max(1.5, inst.radiusN * Math.min(viewport.w, viewport.h));
redrawWaterDraft((existing as any).__fx.g, inst, viewport, halfW);
existing.alpha = Math.max(0.35, Math.min(0.95, inst.opacity * 1.1));
(existing as any).__sig = sig;
continue;
}
if (existing) { if (existing) {
const fx = (existing as any).__fx; const fx = (existing as any).__fx;
fx?.video?.pause?.(); fx?.video?.pause?.();
@@ -12,6 +12,15 @@
pointer-events: none; pointer-events: none;
} }
/** Общий host: клики проходят сквозь dim к сцене; кадры/кнопки ловят сами. */
.hostHitThrough {
pointer-events: none;
}
.captureZoom {
pointer-events: auto;
}
.cursorZoomIn { .cursorZoomIn {
cursor: zoom-in; cursor: zoom-in;
} }
@@ -89,11 +98,22 @@
cursor: nwse-resize; cursor: nwse-resize;
} }
.close { .closeStack {
position: absolute; position: absolute;
top: 14px; top: 14px;
right: 14px; right: 14px;
z-index: 3; z-index: 3;
display: flex;
flex-direction: column;
gap: 8px;
pointer-events: none;
}
.close {
position: relative;
top: auto;
right: auto;
z-index: 3;
width: 36px; width: 36px;
height: 36px; height: 36px;
border: none; border: none;
@@ -105,12 +125,20 @@
cursor: pointer; cursor: pointer;
display: grid; display: grid;
place-items: center; place-items: center;
pointer-events: auto;
} }
.close:hover { .close:hover {
background: rgba(24, 24, 32, 0.9); background: rgba(24, 24, 32, 0.9);
} }
/** Standalone-оверлей (без host): одна кнопка в углу. */
.root > .close {
position: absolute;
top: 14px;
right: 14px;
}
.frameRotate { .frameRotate {
position: absolute; position: absolute;
left: 50%; left: 50%;
+126 -66
View File
@@ -2,6 +2,7 @@ import React, { useEffect, useRef, useState } from 'react';
import type { AssetId, MaterialsOverlayLayout, MaterialsZoomTool, NpcsZoomTool } from '../../../shared/types'; import type { AssetId, MaterialsOverlayLayout, MaterialsZoomTool, NpcsZoomTool } from '../../../shared/types';
import { DEFAULT_MATERIALS_OVERLAY_LAYOUT } from '../../../shared/types'; import { DEFAULT_MATERIALS_OVERLAY_LAYOUT } from '../../../shared/types';
import { useSceneOverlayView } from '../sceneOverlay/SceneOverlayViewContext';
import { useAssetUrl } from '../useAssetImageUrl'; import { useAssetUrl } from '../useAssetImageUrl';
import styles from './MaterialOverlay.module.css'; import styles from './MaterialOverlay.module.css';
@@ -19,6 +20,8 @@ type MaterialOverlayProps = {
rotateLabel?: string; rotateLabel?: string;
onLayoutChange?: (layout: MaterialsOverlayLayout) => void; onLayoutChange?: (layout: MaterialsOverlayLayout) => void;
onZoomAt?: (nx: number, ny: number) => void; onZoomAt?: (nx: number, ny: number) => void;
/** Без собственного root/dim — внутри `SceneOverlayHost`. */
embedded?: boolean;
}; };
function RotateIcon() { function RotateIcon() {
@@ -95,11 +98,21 @@ export function MaterialOverlay({
rotateLabel = 'Rotate', rotateLabel = 'Rotate',
onLayoutChange, onLayoutChange,
onZoomAt, onZoomAt,
embedded = false,
}: MaterialOverlayProps) { }: MaterialOverlayProps) {
const url = useAssetUrl(assetId); const url = useAssetUrl(assetId);
const rootRef = useRef<HTMLDivElement | null>(null); const host = useSceneOverlayView();
const localRootRef = useRef<HTMLDivElement | null>(null);
const rootRef = embedded && host ? host.rootRef : localRootRef;
const [natural, setNatural] = useState<{ w: number; h: number }>({ w: 1600, h: 900 }); const [natural, setNatural] = useState<{ w: number; h: number }>({ w: 1600, h: 900 });
const [view, setView] = useState({ w: 1, h: 1 }); const [localView, setLocalView] = useState({ w: 1, h: 1 });
/** Локальный layout + IPC не чаще 1/frame (лайв, без спама pointermove). */
const [draftLayout, setDraftLayout] = useState<MaterialsOverlayLayout | null>(null);
const pendingLayoutRef = useRef<MaterialsOverlayLayout | null>(null);
const draftRafRef = useRef(0);
const onLayoutChangeRef = useRef(onLayoutChange);
onLayoutChangeRef.current = onLayoutChange;
const view = embedded && host ? host.view : localView;
const dragRef = useRef< const dragRef = useRef<
| { mode: 'move'; startX: number; startY: number; origin: MaterialsOverlayLayout } | { mode: 'move'; startX: number; startY: number; origin: MaterialsOverlayLayout }
| { | {
@@ -124,14 +137,33 @@ export function MaterialOverlay({
>(null); >(null);
useEffect(() => { useEffect(() => {
const el = rootRef.current; if (embedded) return;
const el = localRootRef.current;
if (!el) return; if (!el) return;
const sync = () => setView({ w: el.clientWidth, h: el.clientHeight }); let raf = 0;
const sync = () => {
if (raf !== 0) return;
raf = window.requestAnimationFrame(() => {
raf = 0;
const w = Math.max(1, el.clientWidth);
const h = Math.max(1, el.clientHeight);
setLocalView((prev) => (prev.w === w && prev.h === h ? prev : { w, h }));
});
};
sync(); sync();
const ro = new ResizeObserver(sync); const ro = new ResizeObserver(sync);
ro.observe(el); ro.observe(el);
return () => ro.disconnect(); return () => {
}, [url]); ro.disconnect();
if (raf !== 0) window.cancelAnimationFrame(raf);
};
}, [embedded, url]);
useEffect(() => {
if (dragRef.current) return;
setDraftLayout(null);
pendingLayoutRef.current = null;
}, [layout]);
if (!assetId || !url) return null; if (!assetId || !url) return null;
@@ -139,7 +171,7 @@ export function MaterialOverlay({
const zoomCursor = const zoomCursor =
zoomTool === 'zoomIn' ? styles.cursorZoomIn : zoomTool === 'zoomOut' ? styles.cursorZoomOut : ''; zoomTool === 'zoomIn' ? styles.cursorZoomIn : zoomTool === 'zoomOut' ? styles.cursorZoomOut : '';
const effectiveLayout = layout ?? DEFAULT_MATERIALS_OVERLAY_LAYOUT; const effectiveLayout = draftLayout ?? layout ?? DEFAULT_MATERIALS_OVERLAY_LAYOUT;
const rotationDeg = effectiveLayout.rotationDeg ?? 0; const rotationDeg = effectiveLayout.rotationDeg ?? 0;
const base = nextBaseSize(view.w, view.h, natural.w, natural.h); const base = nextBaseSize(view.w, view.h, natural.w, natural.h);
const w = base.w * effectiveLayout.scale; const w = base.w * effectiveLayout.scale;
@@ -167,6 +199,18 @@ export function MaterialOverlay({
}; };
}; };
const publishDraft = (next: MaterialsOverlayLayout): void => {
pendingLayoutRef.current = next;
if (draftRafRef.current !== 0) return;
draftRafRef.current = window.requestAnimationFrame(() => {
draftRafRef.current = 0;
const pending = pendingLayoutRef.current;
if (!pending) return;
setDraftLayout(pending);
onLayoutChangeRef.current?.(pending);
});
};
const onPointerMove = (e: PointerEvent) => { const onPointerMove = (e: PointerEvent) => {
const drag = dragRef.current; const drag = dragRef.current;
if (!drag || !onLayoutChange) return; if (!drag || !onLayoutChange) return;
@@ -174,7 +218,7 @@ export function MaterialOverlay({
if (drag.mode === 'rotate') { if (drag.mode === 'rotate') {
const angle = pointerAngleDeg(drag.centerX, drag.centerY, e.clientX, e.clientY); const angle = pointerAngleDeg(drag.centerX, drag.centerY, e.clientX, e.clientY);
const delta = shortestAngleDelta(drag.startPointerAngle, angle); const delta = shortestAngleDelta(drag.startPointerAngle, angle);
onLayoutChange({ publishDraft({
...drag.origin, ...drag.origin,
rotationDeg: drag.origin.rotationDeg + delta, rotationDeg: drag.origin.rotationDeg + delta,
}); });
@@ -188,7 +232,7 @@ export function MaterialOverlay({
if (drag.mode === 'move') { if (drag.mode === 'move') {
const dx = (e.clientX - drag.startX) / Math.max(1, r.width); const dx = (e.clientX - drag.startX) / Math.max(1, r.width);
const dy = (e.clientY - drag.startY) / Math.max(1, r.height); const dy = (e.clientY - drag.startY) / Math.max(1, r.height);
onLayoutChange({ publishDraft({
...drag.origin, ...drag.origin,
cx: drag.origin.cx + dx, cx: drag.origin.cx + dx,
cy: drag.origin.cy + dy, cy: drag.origin.cy + dy,
@@ -247,7 +291,7 @@ export function MaterialOverlay({
const localCenterY = nextTop + hh / 2; const localCenterY = nextTop + hh / 2;
const screenOffset = localToScreenOffset(localCenterX, localCenterY, origin.rotationDeg ?? 0); const screenOffset = localToScreenOffset(localCenterX, localCenterY, origin.rotationDeg ?? 0);
onLayoutChange({ publishDraft({
...origin, ...origin,
cx: origin.cx + screenOffset.x / Math.max(1, viewW), cx: origin.cx + screenOffset.x / Math.max(1, viewW),
cy: origin.cy + screenOffset.y / Math.max(1, viewH), cy: origin.cy + screenOffset.y / Math.max(1, viewH),
@@ -259,6 +303,15 @@ export function MaterialOverlay({
dragRef.current = null; dragRef.current = null;
window.removeEventListener('pointermove', onPointerMove); window.removeEventListener('pointermove', onPointerMove);
window.removeEventListener('pointerup', endDrag); window.removeEventListener('pointerup', endDrag);
if (draftRafRef.current !== 0) {
window.cancelAnimationFrame(draftRafRef.current);
draftRafRef.current = 0;
}
const finalLayout = pendingLayoutRef.current;
if (finalLayout) {
setDraftLayout(finalLayout);
onLayoutChangeRef.current?.(finalLayout);
}
}; };
const startDrag = (e: React.PointerEvent, mode: 'move' | 'resize', corner?: Corner) => { const startDrag = (e: React.PointerEvent, mode: 'move' | 'resize', corner?: Corner) => {
@@ -306,9 +359,70 @@ export function MaterialOverlay({
window.addEventListener('pointerup', endDrag); window.addEventListener('pointerup', endDrag);
}; };
const frame = (
<div
className={[styles.frame, editable && !zoomTool ? styles.frameEditable : ''].filter(Boolean).join(' ')}
data-overlay-kind="material"
style={{
left,
top,
width: w,
height: h,
transform: `rotate(${String(rotationDeg)}deg)`,
transformOrigin: 'center center',
}}
onPointerDown={(e) => {
if (zoomTool) return;
startDrag(e, 'move');
}}
>
<img
className={styles.image}
src={url}
alt=""
draggable={false}
style={{
width: w,
height: h,
transform: 'translate(-50%, -50%)',
}}
onLoad={(e) => {
const img = e.currentTarget;
setNatural({ w: img.naturalWidth || 1, h: img.naturalHeight || 1 });
}}
/>
{editable && !zoomTool
? (['nw', 'ne', 'sw', 'se'] as const).map((corner) => (
<button
key={corner}
type="button"
className={[styles.handle, styles[`handle_${corner}`]].join(' ')}
aria-label={corner}
onPointerDown={(e) => startDrag(e, 'resize', corner)}
/>
))
: null}
{editable && !zoomTool && onLayoutChange ? (
<button
type="button"
className={styles.frameRotate}
aria-label={rotateLabel}
title={rotateLabel}
onPointerDown={startRotate}
>
<RotateIcon />
</button>
) : null}
</div>
);
if (embedded) {
return frame;
}
return ( return (
<div <div
ref={rootRef} ref={localRootRef}
className={[styles.root, interactive ? styles.interactive : styles.passive, zoomCursor] className={[styles.root, interactive ? styles.interactive : styles.passive, zoomCursor]
.filter(Boolean) .filter(Boolean)
.join(' ')} .join(' ')}
@@ -322,61 +436,7 @@ export function MaterialOverlay({
}} }}
> >
<div className={styles.dim} /> <div className={styles.dim} />
<div {frame}
className={[styles.frame, editable && !zoomTool ? styles.frameEditable : '']
.filter(Boolean)
.join(' ')}
style={{
left,
top,
width: w,
height: h,
transform: `rotate(${String(rotationDeg)}deg)`,
transformOrigin: 'center center',
}}
onPointerDown={(e) => {
if (zoomTool) return;
startDrag(e, 'move');
}}
>
<img
className={styles.image}
src={url}
alt=""
draggable={false}
style={{
width: w,
height: h,
transform: 'translate(-50%, -50%)',
}}
onLoad={(e) => {
const img = e.currentTarget;
setNatural({ w: img.naturalWidth || 1, h: img.naturalHeight || 1 });
}}
/>
{editable && !zoomTool
? (['nw', 'ne', 'sw', 'se'] as const).map((corner) => (
<button
key={corner}
type="button"
className={[styles.handle, styles[`handle_${corner}`]].join(' ')}
aria-label={corner}
onPointerDown={(e) => startDrag(e, 'resize', corner)}
/>
))
: null}
{editable && !zoomTool && onLayoutChange ? (
<button
type="button"
className={styles.frameRotate}
aria-label={rotateLabel}
title={rotateLabel}
onPointerDown={startRotate}
>
<RotateIcon />
</button>
) : null}
</div>
{showClose ? ( {showClose ? (
<button <button
type="button" type="button"
+94 -35
View File
@@ -3,6 +3,7 @@ import React, { useEffect, useRef, useState } from 'react';
import type { AssetId, NpcId, NpcsOverlayLayout, NpcsZoomTool } from '../../../shared/types'; import type { AssetId, NpcId, NpcsOverlayLayout, NpcsZoomTool } from '../../../shared/types';
import { DEFAULT_NPCS_OVERLAY_LAYOUT } from '../../../shared/types'; import { DEFAULT_NPCS_OVERLAY_LAYOUT } from '../../../shared/types';
import styles from '../materials/MaterialOverlay.module.css'; import styles from '../materials/MaterialOverlay.module.css';
import { useSceneOverlayView } from '../sceneOverlay/SceneOverlayViewContext';
import { useAssetUrl } from '../useAssetImageUrl'; import { useAssetUrl } from '../useAssetImageUrl';
type Corner = 'nw' | 'ne' | 'sw' | 'se'; type Corner = 'nw' | 'ne' | 'sw' | 'se';
@@ -23,6 +24,8 @@ type NpcsSceneOverlayProps = {
rotateLabel?: string; rotateLabel?: string;
onLayoutChange?: (npcId: NpcId, layout: NpcsOverlayLayout) => void; onLayoutChange?: (npcId: NpcId, layout: NpcsOverlayLayout) => void;
onZoomAt?: (npcId: NpcId | undefined, nx: number, ny: number) => void; onZoomAt?: (npcId: NpcId | undefined, nx: number, ny: number) => void;
/** Без собственного root/dim — внутри `SceneOverlayHost`. */
embedded?: boolean;
}; };
function RotateIcon() { function RotateIcon() {
@@ -107,6 +110,11 @@ function NpcAvatarFrame({
}) { }) {
const url = useAssetUrl(item.assetId); const url = useAssetUrl(item.assetId);
const [natural, setNatural] = useState<{ w: number; h: number }>({ w: 1600, h: 900 }); const [natural, setNatural] = useState<{ w: number; h: number }>({ w: 1600, h: 900 });
const [draftLayout, setDraftLayout] = useState<NpcsOverlayLayout | null>(null);
const pendingLayoutRef = useRef<NpcsOverlayLayout | null>(null);
const draftRafRef = useRef(0);
const onLayoutChangeRef = useRef(onLayoutChange);
onLayoutChangeRef.current = onLayoutChange;
const dragRef = useRef< const dragRef = useRef<
| { mode: 'move'; startX: number; startY: number; origin: NpcsOverlayLayout } | { mode: 'move'; startX: number; startY: number; origin: NpcsOverlayLayout }
| { | {
@@ -130,9 +138,15 @@ function NpcAvatarFrame({
| null | null
>(null); >(null);
useEffect(() => {
if (dragRef.current) return;
setDraftLayout(null);
pendingLayoutRef.current = null;
}, [item.layout]);
if (!item.assetId || !url) return null; if (!item.assetId || !url) return null;
const layout = item.layout ?? DEFAULT_NPCS_OVERLAY_LAYOUT; const layout = draftLayout ?? item.layout ?? DEFAULT_NPCS_OVERLAY_LAYOUT;
const rotationDeg = layout.rotationDeg ?? 0; const rotationDeg = layout.rotationDeg ?? 0;
const base = nextBaseSize(view.w, view.h, natural.w, natural.h); const base = nextBaseSize(view.w, view.h, natural.w, natural.h);
const w = base.w * layout.scale; const w = base.w * layout.scale;
@@ -140,6 +154,18 @@ function NpcAvatarFrame({
const left = layout.cx * view.w - w / 2; const left = layout.cx * view.w - w / 2;
const top = layout.cy * view.h - h / 2; const top = layout.cy * view.h - h / 2;
const publishDraft = (next: NpcsOverlayLayout): void => {
pendingLayoutRef.current = next;
if (draftRafRef.current !== 0) return;
draftRafRef.current = window.requestAnimationFrame(() => {
draftRafRef.current = 0;
const pending = pendingLayoutRef.current;
if (!pending) return;
setDraftLayout(pending);
onLayoutChangeRef.current?.(item.npcId, pending);
});
};
const onPointerMove = (e: PointerEvent) => { const onPointerMove = (e: PointerEvent) => {
const drag = dragRef.current; const drag = dragRef.current;
if (!drag || !onLayoutChange) return; if (!drag || !onLayoutChange) return;
@@ -147,7 +173,7 @@ function NpcAvatarFrame({
if (drag.mode === 'rotate') { if (drag.mode === 'rotate') {
const angle = pointerAngleDeg(drag.centerX, drag.centerY, e.clientX, e.clientY); const angle = pointerAngleDeg(drag.centerX, drag.centerY, e.clientX, e.clientY);
const delta = shortestAngleDelta(drag.startPointerAngle, angle); const delta = shortestAngleDelta(drag.startPointerAngle, angle);
onLayoutChange(item.npcId, { publishDraft({
...drag.origin, ...drag.origin,
rotationDeg: drag.origin.rotationDeg + delta, rotationDeg: drag.origin.rotationDeg + delta,
}); });
@@ -161,7 +187,7 @@ function NpcAvatarFrame({
if (drag.mode === 'move') { if (drag.mode === 'move') {
const dx = (e.clientX - drag.startX) / Math.max(1, r.width); const dx = (e.clientX - drag.startX) / Math.max(1, r.width);
const dy = (e.clientY - drag.startY) / Math.max(1, r.height); const dy = (e.clientY - drag.startY) / Math.max(1, r.height);
onLayoutChange(item.npcId, { publishDraft({
...drag.origin, ...drag.origin,
cx: drag.origin.cx + dx, cx: drag.origin.cx + dx,
cy: drag.origin.cy + dy, cy: drag.origin.cy + dy,
@@ -220,7 +246,7 @@ function NpcAvatarFrame({
const localCenterY = nextTop + hh / 2; const localCenterY = nextTop + hh / 2;
const screenOffset = localToScreenOffset(localCenterX, localCenterY, origin.rotationDeg ?? 0); const screenOffset = localToScreenOffset(localCenterX, localCenterY, origin.rotationDeg ?? 0);
onLayoutChange(item.npcId, { publishDraft({
...origin, ...origin,
cx: origin.cx + screenOffset.x / Math.max(1, viewW), cx: origin.cx + screenOffset.x / Math.max(1, viewW),
cy: origin.cy + screenOffset.y / Math.max(1, viewH), cy: origin.cy + screenOffset.y / Math.max(1, viewH),
@@ -232,6 +258,15 @@ function NpcAvatarFrame({
dragRef.current = null; dragRef.current = null;
window.removeEventListener('pointermove', onPointerMove); window.removeEventListener('pointermove', onPointerMove);
window.removeEventListener('pointerup', endDrag); window.removeEventListener('pointerup', endDrag);
if (draftRafRef.current !== 0) {
window.cancelAnimationFrame(draftRafRef.current);
draftRafRef.current = 0;
}
const finalLayout = pendingLayoutRef.current;
if (finalLayout) {
setDraftLayout(finalLayout);
onLayoutChangeRef.current?.(item.npcId, finalLayout);
}
}; };
const layoutCenterClient = () => { const layoutCenterClient = () => {
@@ -288,6 +323,7 @@ function NpcAvatarFrame({
<div <div
className={[styles.frame, editable && !zoomTool ? styles.frameEditable : ''].filter(Boolean).join(' ')} className={[styles.frame, editable && !zoomTool ? styles.frameEditable : ''].filter(Boolean).join(' ')}
data-npc-id={item.npcId} data-npc-id={item.npcId}
data-overlay-kind="npc"
style={{ style={{
left, left,
top, top,
@@ -352,19 +388,36 @@ export function NpcsSceneOverlay({
rotateLabel = 'Rotate', rotateLabel = 'Rotate',
onLayoutChange, onLayoutChange,
onZoomAt, onZoomAt,
embedded = false,
}: NpcsSceneOverlayProps) { }: NpcsSceneOverlayProps) {
const rootRef = useRef<HTMLDivElement | null>(null); const host = useSceneOverlayView();
const [view, setView] = useState({ w: 1, h: 1 }); const localRootRef = useRef<HTMLDivElement | null>(null);
const rootRef = embedded && host ? host.rootRef : localRootRef;
const [localView, setLocalView] = useState({ w: 1, h: 1 });
const view = embedded && host ? host.view : localView;
useEffect(() => { useEffect(() => {
const el = rootRef.current; if (embedded) return;
const el = localRootRef.current;
if (!el) return; if (!el) return;
const sync = () => setView({ w: el.clientWidth, h: el.clientHeight }); let raf = 0;
const sync = () => {
if (raf !== 0) return;
raf = window.requestAnimationFrame(() => {
raf = 0;
const w = Math.max(1, el.clientWidth);
const h = Math.max(1, el.clientHeight);
setLocalView((prev) => (prev.w === w && prev.h === h ? prev : { w, h }));
});
};
sync(); sync();
const ro = new ResizeObserver(sync); const ro = new ResizeObserver(sync);
ro.observe(el); ro.observe(el);
return () => ro.disconnect(); return () => {
}, [items.length]); ro.disconnect();
if (raf !== 0) window.cancelAnimationFrame(raf);
};
}, [embedded, items.length]);
if (items.length === 0) return null; if (items.length === 0) return null;
@@ -389,9 +442,38 @@ export function NpcsSceneOverlay({
return raw ? (raw as NpcId) : undefined; return raw ? (raw as NpcId) : undefined;
}; };
const frames = items.map((item) =>
onLayoutChange ? (
<NpcAvatarFrame
key={item.npcId}
item={item}
view={view}
rootRef={rootRef}
editable={editable}
zoomTool={zoomTool}
rotateLabel={rotateLabel}
onLayoutChange={onLayoutChange}
/>
) : (
<NpcAvatarFrame
key={item.npcId}
item={item}
view={view}
rootRef={rootRef}
editable={editable}
zoomTool={zoomTool}
rotateLabel={rotateLabel}
/>
),
);
if (embedded) {
return <>{frames}</>;
}
return ( return (
<div <div
ref={rootRef} ref={localRootRef}
className={[styles.root, interactive ? styles.interactive : styles.passive, zoomCursor] className={[styles.root, interactive ? styles.interactive : styles.passive, zoomCursor]
.filter(Boolean) .filter(Boolean)
.join(' ')} .join(' ')}
@@ -405,30 +487,7 @@ export function NpcsSceneOverlay({
}} }}
> >
<div className={styles.dim} /> <div className={styles.dim} />
{items.map((item) => {frames}
onLayoutChange ? (
<NpcAvatarFrame
key={item.npcId}
item={item}
view={view}
rootRef={rootRef}
editable={editable}
zoomTool={zoomTool}
rotateLabel={rotateLabel}
onLayoutChange={onLayoutChange}
/>
) : (
<NpcAvatarFrame
key={item.npcId}
item={item}
view={view}
rootRef={rootRef}
editable={editable}
zoomTool={zoomTool}
rotateLabel={rotateLabel}
/>
),
)}
{showClose ? ( {showClose ? (
<button <button
type="button" type="button"
@@ -0,0 +1,116 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import type { MaterialsZoomTool, NpcsZoomTool } from '../../../shared/types';
import styles from '../materials/MaterialOverlay.module.css';
import { SceneOverlayViewContext } from './SceneOverlayViewContext';
export type SceneOverlayCloseAction = {
key: string;
label: string;
onClose: () => void;
};
type SceneOverlayHostProps = {
/** Есть ли что показывать (материал и/или NPC). */
active: boolean;
zoomTool?: MaterialsZoomTool | NpcsZoomTool;
onZoomAt?: (nx: number, ny: number, target: EventTarget | null) => void;
closes?: readonly SceneOverlayCloseAction[];
children: React.ReactNode;
};
/**
* Общий слой подложки для Materials + NPCs: один root и один `.dim`.
* Кадры остаются в дочерних оверлеях (`embedded`).
*/
export function SceneOverlayHost({
active,
zoomTool = null,
onZoomAt,
closes = [],
children,
}: SceneOverlayHostProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const [view, setView] = useState({ w: 1, h: 1 });
useEffect(() => {
const el = rootRef.current;
if (!el) return;
let raf = 0;
const sync = () => {
if (raf !== 0) return;
raf = window.requestAnimationFrame(() => {
raf = 0;
const w = Math.max(1, el.clientWidth);
const h = Math.max(1, el.clientHeight);
setView((prev) => (prev.w === w && prev.h === h ? prev : { w, h }));
});
};
sync();
const ro = new ResizeObserver(sync);
ro.observe(el);
return () => {
ro.disconnect();
if (raf !== 0) window.cancelAnimationFrame(raf);
};
}, [active]);
const ctx = useMemo(() => ({ rootRef, view }), [view]);
if (!active) return null;
const captureZoom = Boolean(zoomTool && onZoomAt);
const zoomCursor =
zoomTool === 'zoomIn' ? styles.cursorZoomIn : zoomTool === 'zoomOut' ? styles.cursorZoomOut : '';
const toNorm = (clientX: number, clientY: number) => {
const root = rootRef.current;
if (!root) return { nx: 0.5, ny: 0.5 };
const r = root.getBoundingClientRect();
return {
nx: (clientX - r.left) / Math.max(1, r.width),
ny: (clientY - r.top) / Math.max(1, r.height),
};
};
return (
<SceneOverlayViewContext.Provider value={ctx}>
<div
ref={rootRef}
className={[styles.root, styles.hostHitThrough, captureZoom ? styles.captureZoom : '', zoomCursor]
.filter(Boolean)
.join(' ')}
role="presentation"
onClick={(e) => {
if (!captureZoom || !onZoomAt) return;
e.stopPropagation();
const { nx, ny } = toNorm(e.clientX, e.clientY);
onZoomAt(nx, ny, e.target);
}}
>
<div className={styles.dim} />
{children}
{closes.length > 0 ? (
<div className={styles.closeStack}>
{closes.map((c) => (
<button
key={c.key}
type="button"
className={styles.close}
onClick={(e) => {
e.stopPropagation();
c.onClose();
}}
aria-label={c.label}
title={c.label}
>
×
</button>
))}
</div>
) : null}
</div>
</SceneOverlayViewContext.Provider>
);
}
@@ -0,0 +1,12 @@
import React, { createContext, useContext } from 'react';
export type SceneOverlayViewContextValue = {
rootRef: React.RefObject<HTMLDivElement | null>;
view: { w: number; h: number };
};
export const SceneOverlayViewContext = createContext<SceneOverlayViewContextValue | null>(null);
export function useSceneOverlayView(): SceneOverlayViewContextValue | null {
return useContext(SceneOverlayViewContext);
}
@@ -0,0 +1,79 @@
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));
const root = path.resolve(here, '..', '..', '..', '..');
void test('SceneOverlayHost: один dim для materials + npcs в Control и Presentation', () => {
const host = fs.readFileSync(path.join(here, 'SceneOverlayHost.tsx'), 'utf8');
const control = fs.readFileSync(path.join(root, 'app/renderer/control/ControlApp.tsx'), 'utf8');
const presentation = fs.readFileSync(path.join(root, 'app/renderer/shared/PresentationView.tsx'), 'utf8');
const css = fs.readFileSync(
path.join(root, 'app/renderer/shared/materials/MaterialOverlay.module.css'),
'utf8',
);
assert.ok(host.includes('styles.dim'));
assert.ok(host.includes('hostHitThrough'));
assert.ok(control.includes('SceneOverlayHost'));
assert.ok(control.includes('embedded'));
assert.ok(presentation.includes('SceneOverlayHost'));
assert.ok(presentation.includes('embedded'));
// Control/Presentation монтируют оверлеи только как embedded внутри host.
assert.ok(control.includes('<MaterialOverlay'));
assert.ok(control.includes('<NpcsSceneOverlay'));
assert.ok(control.includes('embedded'));
assert.ok(presentation.includes('embedded'));
assert.match(css, /\.hostHitThrough\s*\{[^}]*pointer-events:\s*none/s);
});
void test('MaterialOverlay / NpcsSceneOverlay поддерживают embedded без собственного dim', () => {
const material = fs.readFileSync(
path.join(root, 'app/renderer/shared/materials/MaterialOverlay.tsx'),
'utf8',
);
const npcs = fs.readFileSync(path.join(root, 'app/renderer/shared/npcs/NpcsSceneOverlay.tsx'), 'utf8');
assert.ok(material.includes('embedded'));
assert.ok(material.includes('useSceneOverlayView'));
assert.ok(npcs.includes('embedded'));
assert.ok(npcs.includes('useSceneOverlayView'));
// В embedded-ветке не рисуем второй dim.
assert.match(material, /if \(embedded\) \{\s*return frame;/);
assert.match(npcs, /if \(embedded\) \{\s*return <>\{frames\}<\/>;/);
});
void test('overlays: layout IPC live через rAF coalesce, RO host coalesced', () => {
const host = fs.readFileSync(path.join(here, 'SceneOverlayHost.tsx'), 'utf8');
const material = fs.readFileSync(
path.join(root, 'app/renderer/shared/materials/MaterialOverlay.tsx'),
'utf8',
);
const npcs = fs.readFileSync(path.join(root, 'app/renderer/shared/npcs/NpcsSceneOverlay.tsx'), 'utf8');
assert.ok(host.includes('requestAnimationFrame'));
assert.ok(host.includes('prev.w === w && prev.h === h'));
// Лайв: publishDraft шлёт onLayoutChange внутри rAF (не на каждый pointermove).
assert.ok(material.includes('publishDraft'));
assert.ok(material.includes('onLayoutChangeRef'));
assert.match(material, /requestAnimationFrame\(\(\) => \{[\s\S]*?onLayoutChangeRef\.current\?\.\(pending\)/);
assert.match(
material,
/if \(drag\.mode === 'move'\) \{[\s\S]*?publishDraft\(\{[\s\S]*?return;\s*\}/,
);
assert.ok(npcs.includes('publishDraft'));
assert.ok(npcs.includes('onLayoutChangeRef'));
assert.match(
npcs,
/requestAnimationFrame\(\(\) => \{[\s\S]*?onLayoutChangeRef\.current\?\.\(item\.npcId, pending\)/,
);
assert.match(
npcs,
/if \(drag\.mode === 'move'\) \{[\s\S]*?publishDraft\(\{[\s\S]*?return;\s*\}/,
);
});
@@ -0,0 +1,24 @@
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));
/** Регресс: списки сцен/материалов не должны дёргать assetFileUrl на каждый mount одного id. */
void test('useAssetUrl: module cache + invalidate при смене проекта', () => {
const src = fs.readFileSync(path.join(here, 'useAssetImageUrl.ts'), 'utf8');
const projectState = fs.readFileSync(path.join(here, '../editor/state/projectState.ts'), 'utf8');
assert.ok(src.includes('urlCache'));
assert.ok(src.includes('invalidateAssetUrlCache'));
assert.ok(src.includes('peekAssetUrlCache'));
assert.ok(src.includes('session.stateChanged'));
assert.match(src, /urlCache\.set\(id,\s*r\.url\)/);
assert.match(src, /peekAssetUrlCache\(id\)/);
assert.ok(projectState.includes('invalidateAssetUrlCache'));
assert.match(projectState, /openProject[\s\S]*?invalidateAssetUrlCache\(\)/);
assert.match(projectState, /closeProject[\s\S]*?invalidateAssetUrlCache\(\)/);
});
+67 -4
View File
@@ -1,31 +1,94 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { ipcChannels } from '../../shared/ipc/contracts'; import { ipcChannels } from '../../shared/ipc/contracts';
import type { AssetId } from '../../shared/types'; import type { AssetId, ProjectId } from '../../shared/types';
import { getDndApi } from './dndApi'; import { getDndApi } from './dndApi';
/** Module-level кэш assetId → url; сбрасывается при смене/закрытии проекта. */
const urlCache = new Map<AssetId, string | null>();
const invalidateListeners = new Set<() => void>();
let sessionProjectHooked = false;
let lastSessionProjectId: ProjectId | null | undefined;
function ensureSessionProjectInvalidation(): void {
if (sessionProjectHooked) return;
sessionProjectHooked = true;
try {
getDndApi().on(ipcChannels.session.stateChanged, ({ state }) => {
const next = state.project?.id ?? null;
if (lastSessionProjectId === undefined) {
lastSessionProjectId = next;
return;
}
if (lastSessionProjectId !== next) {
lastSessionProjectId = next;
invalidateAssetUrlCache();
}
});
} catch {
/* вне Electron / тесты */
}
}
export function peekAssetUrlCache(assetId: AssetId): string | null | undefined {
return urlCache.has(assetId) ? (urlCache.get(assetId) ?? null) : undefined;
}
export function invalidateAssetUrlCache(): void {
urlCache.clear();
for (const fn of invalidateListeners) {
try {
fn();
} catch {
/* ignore */
}
}
}
/** /**
* Возвращает `file://` URL для превью изображения. Пока загрузка или сменился id — `null`. * Возвращает `dnd://` / file URL для превью. Пока загрузка или сменился id — `null`.
* Повторные запросы того же id не ходят в IPC, пока кэш не инвалидирован.
*/ */
export function useAssetUrl(assetId: AssetId | null | undefined): string | null { export function useAssetUrl(assetId: AssetId | null | undefined): string | null {
ensureSessionProjectInvalidation();
const id = assetId ?? null; const id = assetId ?? null;
const [entry, setEntry] = useState<{ assetId: AssetId; url: string | null } | null>(null); const [entry, setEntry] = useState<{ assetId: AssetId; url: string | null } | null>(() => {
if (id === null) return null;
const hit = peekAssetUrlCache(id);
return hit === undefined ? null : { assetId: id, url: hit };
});
const [epoch, setEpoch] = useState(0);
useEffect(() => {
const onInvalidate = () => setEpoch((n) => n + 1);
invalidateListeners.add(onInvalidate);
return () => {
invalidateListeners.delete(onInvalidate);
};
}, []);
useEffect(() => { useEffect(() => {
if (id === null) { if (id === null) {
setEntry(null);
return undefined;
}
const hit = peekAssetUrlCache(id);
if (hit !== undefined) {
setEntry({ assetId: id, url: hit });
return undefined; return undefined;
} }
let cancelled = false; let cancelled = false;
void getDndApi() void getDndApi()
.invoke(ipcChannels.project.assetFileUrl, { assetId: id }) .invoke(ipcChannels.project.assetFileUrl, { assetId: id })
.then((r) => { .then((r) => {
urlCache.set(id, r.url);
if (!cancelled) setEntry({ assetId: id, url: r.url }); if (!cancelled) setEntry({ assetId: id, url: r.url });
}); });
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [id]); }, [id, epoch]);
if (id === null) { if (id === null) {
return null; return null;
+1 -1
View File
@@ -10,7 +10,7 @@
"build:obfuscate": "node scripts/build.mjs --production --obfuscate", "build:obfuscate": "node scripts/build.mjs --production --obfuscate",
"lint": "eslint . --max-warnings 0", "lint": "eslint . --max-warnings 0",
"typecheck": "tsc -p tsconfig.eslint.json --noEmit", "typecheck": "tsc -p tsconfig.eslint.json --noEmit",
"test": "tsx --test app/renderer/shared/ui/controls.tooltip.test.ts app/renderer/editor/state/projectState.race.test.ts app/renderer/editor/fileDrop.test.ts app/shared/graph/sceneListOrder.test.ts app/renderer/editor/graph/sceneCardById.test.ts app/renderer/editor/i18n/editorMessages.locale.test.ts app/renderer/editor/sceneDescriptionHtml.test.ts app/shared/graph/storylineExportImport.test.ts app/shared/foundry/foundryGraph.test.ts app/shared/ipc/contracts.mediaRemoval.test.ts app/shared/effectEraserHitTest.test.ts app/shared/fieldEffectEraser.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/effects/sceneDarknessStore.test.ts app/main/project/assetPrune.test.ts app/main/project/optimizeImageImport.test.ts app/main/project/scenePreviewThumbnail.test.ts app/main/project/fsRetry.test.ts app/main/project/zipRead.test.ts app/main/project/replaceFileAtomic.test.ts app/main/project/zipStore.legacyContract.test.ts app/shared/package.build.test.ts app/shared/license/canonicalJson.test.ts app/shared/license/productKey.test.ts app/shared/license/licenseService.networkRegression.test.ts app/shared/video/videoPlaybackPerf.networkRegression.test.ts app/shared/video/videoPlaybackLoop.networkRegression.test.ts app/main/license/verifyLicenseToken.test.ts app/main/license/machineFingerprint.test.ts && node --test scripts/build-env.test.mjs scripts/obfuscate-main.test.mjs scripts/release-mac-prep.test.mjs", "test": "tsx --test app/renderer/shared/ui/controls.tooltip.test.ts app/renderer/editor/state/projectState.race.test.ts app/renderer/editor/fileDrop.test.ts app/shared/graph/sceneListOrder.test.ts app/renderer/editor/graph/sceneCardById.test.ts app/renderer/editor/i18n/editorMessages.locale.test.ts app/renderer/editor/sceneDescriptionHtml.test.ts app/shared/graph/storylineExportImport.test.ts app/shared/foundry/foundryGraph.test.ts app/shared/ipc/contracts.mediaRemoval.test.ts app/shared/effectEraserHitTest.test.ts app/shared/fieldEffectEraser.test.ts app/renderer/control/controlApp.effectsPanel.test.ts app/renderer/control/controlApp.audioPerf.networkRegression.test.ts app/renderer/control/controlApp.brushPerf.networkRegression.test.ts app/renderer/shared/useAssetImageUrl.cache.test.ts app/renderer/shared/sceneOverlay/sceneOverlayHost.test.ts app/renderer/shared/effects/PxiEffectsOverlay.pointer.test.ts app/main/windows/createWindows.editorClose.test.ts app/main/safeConsole.test.ts app/main/windows/bootWindow.test.ts app/main/effects/effectsStore.test.ts app/main/effects/sceneDarknessStore.test.ts app/main/project/assetPrune.test.ts app/main/project/optimizeImageImport.test.ts app/main/project/scenePreviewThumbnail.test.ts app/main/project/fsRetry.test.ts app/main/project/zipRead.test.ts app/main/project/replaceFileAtomic.test.ts app/main/project/zipStore.legacyContract.test.ts app/shared/package.build.test.ts app/shared/license/canonicalJson.test.ts app/shared/license/productKey.test.ts app/shared/license/licenseService.networkRegression.test.ts app/shared/video/videoPlaybackPerf.networkRegression.test.ts app/shared/video/videoPlaybackLoop.networkRegression.test.ts app/main/license/verifyLicenseToken.test.ts app/main/license/machineFingerprint.test.ts && node --test scripts/build-env.test.mjs scripts/obfuscate-main.test.mjs scripts/release-mac-prep.test.mjs",
"format": "prettier . --check", "format": "prettier . --check",
"format:write": "prettier . --write", "format:write": "prettier . --write",
"postinstall": "patch-package", "postinstall": "patch-package",