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 { installStdoutEpipeGuards } from './safeConsole';
installStdoutEpipeGuards();
import { openDialogFilterLabel } from '../shared/appBranding';
import { ipcChannels, type ScenePreviewImportEvent, type SessionState } from '../shared/ipc/contracts';
import {
+3 -2
View File
@@ -25,7 +25,8 @@ export function clearLegacyDeviceId(userData: string): void {
/**
* Идентификатор устройства для лицензии: отпечаток физической машины.
* Одинаков для всех пользователей ОС на одном ПК (Windows/macOS/Linux).
* `userData` — путь для дискового кэша fingerprint (без повторного reg/wmic).
*/
export function getOrCreateDeviceId(_userData?: string): string {
return resolveMachineFingerprint();
export function getOrCreateDeviceId(userData?: string): string {
return resolveMachineFingerprint(userData ? { userData } : {});
}
@@ -5,6 +5,7 @@ import path from 'node:path';
import test from 'node:test';
import {
clearMachineFingerprintMemoryCache,
hashMachineRawId,
machineWideIdPath,
parseMacIOPlatformUUID,
@@ -12,6 +13,7 @@ import {
parseWmicUuid,
resolveMachineFingerprint,
} from './machineFingerprint';
import { machineFingerprintCachePath } from './paths';
void test('hashMachineRawId: стабилен и не зависит от регистра GUID', () => {
const a = hashMachineRawId('win32', 'ABCDEF00-1111-2222-3333-444455556666');
@@ -46,6 +48,7 @@ void test('parseWmicUuid', () => {
});
void test('resolveMachineFingerprint: override через DND_LICENSE_DEVICE_ID', () => {
clearMachineFingerprintMemoryCache();
const id = resolveMachineFingerprint({
platform: 'linux',
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', () => {
clearMachineFingerprintMemoryCache();
const exec = () =>
`
HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Cryptography
@@ -64,6 +68,7 @@ HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Cryptography
env: {},
execFileSync: exec as never,
});
clearMachineFingerprintMemoryCache();
const b = resolveMachineFingerprint({
platform: 'win32',
env: {},
@@ -74,6 +79,7 @@ HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Cryptography
});
void test('resolveMachineFingerprint: Linux /etc/machine-id', () => {
clearMachineFingerprintMemoryCache();
const id = resolveMachineFingerprint({
platform: 'linux',
env: {},
@@ -86,6 +92,7 @@ void test('resolveMachineFingerprint: Linux /etc/machine-id', () => {
});
void test('resolveMachineFingerprint: fallback в machine-wide путь', () => {
clearMachineFingerprintMemoryCache();
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'machine-fp-'));
const env = { PROGRAMDATA: tmp };
const p = machineWideIdPath('win32', env);
@@ -96,6 +103,7 @@ void test('resolveMachineFingerprint: fallback в machine-wide путь', () =>
throw new Error('no reg');
},
});
clearMachineFingerprintMemoryCache();
const id2 = resolveMachineFingerprint({
platform: 'win32',
env,
@@ -107,3 +115,37 @@ void test('resolveMachineFingerprint: fallback в machine-wide путь', () =>
assert.ok(fs.existsSync(p));
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 path from 'node:path';
import { machineFingerprintCachePath } from './paths';
type ExecFile = (
file: string,
args: readonly string[],
@@ -13,6 +15,8 @@ type ExecFile = (
export type MachineFingerprintDeps = {
platform?: NodeJS.Platform;
env?: NodeJS.ProcessEnv;
/** Electron userData — для дискового кэша hashed fingerprint. */
userData?: string;
execFileSync?: ExecFile;
readFileSync?: (p: string, encoding: 'utf8') => string;
existsSync?: (p: string) => boolean;
@@ -20,6 +24,18 @@ export type MachineFingerprintDeps = {
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';
/** Стабильный 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.
* Кэш: память процесса → userData/machine.fingerprint → sync probe ОС только при miss.
*/
export function resolveMachineFingerprint(deps: MachineFingerprintDeps = {}): string {
const platform = deps.platform ?? process.platform;
@@ -167,7 +212,24 @@ export function resolveMachineFingerprint(deps: MachineFingerprintDeps = {}): st
});
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;
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');
}
/** Кэш hashed machine fingerprint — без повторного reg/wmic/ioreg на каждом старте. */
export function machineFingerprintCachePath(userData: string): string {
return path.join(userData, 'machine.fingerprint');
}
export function preferencesPath(userData: string): string {
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('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 { ipcChannels } from '../../shared/ipc/contracts';
import { safeConsoleError } from '../safeConsole';
import { getBootSplashWindow } from './bootWindow';
import { loadBrandingWindowIcon } from './brandingIcon';
@@ -245,13 +247,16 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
}
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) => {
console.error(`[did-fail-load] ${String(errorCode)} ${errorDescription} ${validatedURL}`);
win.webContents.on('did-fail-load', (_event, errorCode, errorDescription, validatedURL, isMainFrame) => {
// -3 ERR_ABORTED: частый артефакт при navigate/maximize/закрытии — не шумим.
if (errorCode === -3) return;
if (!isMainFrame) return;
safeConsoleError(`[did-fail-load] ${String(errorCode)} ${errorDescription} ${validatedURL}`);
});
win.webContents.on('render-process-gone', (_event, details) => {
console.error('[render-process-gone]', details.reason, details.exitCode);
safeConsoleError('[render-process-gone]', details.reason, details.exitCode);
});
if (!deferEditor) {
+48 -1
View File
@@ -4,14 +4,20 @@
display: grid;
grid-template-columns: 280px 1fr;
gap: 16px;
overflow: auto;
min-height: 0;
box-sizing: border-box;
}
.remote {
padding: 12px;
height: 100%;
min-height: 0;
min-height: calc(100vh - 32px);
min-width: 0;
overflow: hidden;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
.remoteTitle {
@@ -456,10 +462,51 @@
white-space: nowrap;
}
.audioControls {
display: flex;
flex-direction: column;
gap: 8px;
flex-shrink: 0;
align-items: stretch;
min-width: 132px;
}
.audioTransport {
display: flex;
gap: 10px;
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 {
+335 -372
View File
@@ -13,7 +13,11 @@ import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
import { isSceneDescriptionEmpty } from '../editor/sceneDescriptionHtml';
import { getDndApi } from '../shared/dndApi';
import { RotatedImage } from '../shared/RotatedImage';
import { PixiEffectsOverlay } from '../shared/effects/PxiEffectsOverlay';
import {
PixiEffectsOverlay,
type PixiEffectsOverlayHandle,
} from '../shared/effects/PxiEffectsOverlay';
import type { EffectInstance } from '../../shared/types/effects';
import { SceneDarknessOverlay } from '../shared/effects/SceneDarknessOverlay';
import { useEffectsState } from '../shared/effects/useEffectsState';
import { useSceneDarknessState } from '../shared/effects/useSceneDarknessState';
@@ -22,11 +26,13 @@ import { MaterialOverlay } from '../shared/materials/MaterialOverlay';
import { useMaterialsOverlayState } from '../shared/materials/useMaterialsOverlayState';
import { NpcsSceneOverlay } from '../shared/npcs/NpcsSceneOverlay';
import { useNpcsOverlayState } from '../shared/npcs/useNpcsOverlayState';
import { SceneOverlayHost } from '../shared/sceneOverlay/SceneOverlayHost';
import { Button } from '../shared/ui/controls';
import { Surface } from '../shared/ui/Surface';
import { useAssetUrl } from '../shared/useAssetImageUrl';
import styles from './ControlApp.module.css';
import { ControlAudioCard } from './ControlAudioCard';
import { ControlScenePreview } from './ControlScenePreview';
import { getFreezeEffectLifeMs, playFreezeEffectSound } from './freezeSfx';
import { getPoisonCloudEffectLifeMs, playPoisonCloudEffectSound } from './poisonCloudSfx';
@@ -35,12 +41,23 @@ import { getSunbeamEffectLifeMs, playSunbeamEffectSound } from './sunbeamSfx';
/** Длительность молнии: быстрый удар + акцент в точке попадания. */
const LIGHTNING_EFFECT_MS = 840;
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 readAudioGain(gains: Map<string, number>, assetId: string): number {
return gains.get(assetId) ?? 1;
}
/** Применяет пользовательскую громкость; `factor` — для fade in/out (0…1). */
function applyAudioGain(
el: HTMLAudioElement,
gains: Map<string, number>,
assetId: string,
factor = 1,
): void {
el.volume = clampAudioGain(readAudioGain(gains, assetId) * factor);
}
/** Файл из `app/renderer/public/molniya.mp3` — рядом с `control.html` в dev и в dist. */
@@ -103,11 +120,14 @@ export function ControlApp() {
// Сюжетная линия — только UI-состояние пульта. Не меняет граф, сцены и связи проекта.
const sceneAudioElsRef = useRef<Map<string, HTMLAudioElement>>(new Map());
const sceneAudioMetaRef = useRef<Map<string, { lastPlayError: string | null }>>(new Map());
/** Пользовательская громкость 0…1 по assetId (сохраняется между сменами сцен для того же трека). */
const sceneAudioGainRef = useRef<Map<string, number>>(new Map());
const [sceneAudioStateTick, setSceneAudioStateTick] = useState(0);
const sceneAudioLoadRunRef = useRef(0);
const campaignAudioElsRef = useRef<Map<string, HTMLAudioElement>>(new Map());
const campaignAudioMetaRef = useRef<Map<string, { lastPlayError: string | null }>>(new Map());
const campaignAudioGainRef = useRef<Map<string, number>>(new Map());
const [campaignAudioStateTick, setCampaignAudioStateTick] = useState(0);
const campaignAudioLoadRunRef = useRef(0);
/** Snapshot of `!el.paused` per assetId when scene music takes over; used to resume when `allowCampaignAudio` is true again. */
@@ -132,7 +152,6 @@ export function ControlApp() {
startN?: { x: number; y: number };
points?: { x: number; y: number; tMs: number }[];
} | null>(null);
const [draftFxTick, setDraftFxTick] = useState(0);
const [previewSize, setPreviewSize] = useState<{ w: number; h: number }>({ w: 1, h: 1 });
const [previewContentRect, setPreviewContentRect] = useState<{
x: number;
@@ -147,6 +166,8 @@ export function ControlApp() {
const brushCursorElRef = useRef<HTMLDivElement | null>(null);
const cursorPosRef = useRef<{ x: number; y: number } | null>(null);
const draftPaintRafRef = useRef(0);
const effectsOverlayRef = useRef<PixiEffectsOverlayHandle | null>(null);
const draftMetaRef = useRef<{ createdAtMs: number; seed: number } | null>(null);
useEffect(() => {
void api.invoke(ipcChannels.project.get, {}).then((res) => {
@@ -191,21 +212,29 @@ export function ControlApp() {
}, []);
const [freezeDraftLifeMs, setFreezeDraftLifeMs] = useState(820);
const freezeDraftLifeMsRef = useRef(freezeDraftLifeMs);
freezeDraftLifeMsRef.current = freezeDraftLifeMs;
useEffect(() => {
void getFreezeEffectLifeMs().then(setFreezeDraftLifeMs);
}, []);
const [darknessDraftLifeMs, setDarknessDraftLifeMs] = useState(820);
const darknessDraftLifeMsRef = useRef(darknessDraftLifeMs);
darknessDraftLifeMsRef.current = darknessDraftLifeMs;
useEffect(() => {
void getFreezeEffectLifeMs().then(setDarknessDraftLifeMs);
}, []);
const [sunbeamDraftLifeMs, setSunbeamDraftLifeMs] = useState(600);
const sunbeamDraftLifeMsRef = useRef(sunbeamDraftLifeMs);
sunbeamDraftLifeMsRef.current = sunbeamDraftLifeMs;
useEffect(() => {
void getSunbeamEffectLifeMs().then(setSunbeamDraftLifeMs);
}, []);
const [poisonDraftLifeMs, setPoisonDraftLifeMs] = useState(1600);
const poisonDraftLifeMsRef = useRef(poisonDraftLifeMs);
poisonDraftLifeMsRef.current = poisonDraftLifeMs;
useEffect(() => {
void getPoisonCloudEffectLifeMs().then(setPoisonDraftLifeMs);
}, []);
@@ -268,11 +297,11 @@ export function ControlApp() {
const FADE_OUT_MS = 450;
const fadeOutCtl = { raf: 0, cancelled: false };
const finishFadeOut = (): void => {
for (const el of oldEls.values()) {
for (const [id, el] of oldEls) {
try {
el.pause();
el.currentTime = 0;
el.volume = 1;
applyAudioGain(el, sceneAudioGainRef.current, id);
} catch {
// ignore
}
@@ -324,7 +353,8 @@ export function ControlApp() {
const el = new Audio(r.url);
el.loop = item.loop;
el.preload = 'auto';
el.volume = item.autoplay ? 0 : 1;
if (item.autoplay) el.volume = 0;
else applyAudioGain(el, sceneAudioGainRef.current, item.assetId);
sceneAudioMetaRef.current.set(item.assetId, { lastPlayError: null });
el.addEventListener('play', () => setSceneAudioStateTick((x) => x + 1));
el.addEventListener('pause', () => setSceneAudioStateTick((x) => x + 1));
@@ -340,7 +370,7 @@ export function ControlApp() {
try {
el.pause();
el.currentTime = 0;
el.volume = 1;
applyAudioGain(el, sceneAudioGainRef.current, ref.assetId);
} catch {
// ignore
}
@@ -357,7 +387,7 @@ export function ControlApp() {
});
setSceneAudioStateTick((x) => x + 1);
try {
el.volume = 1;
applyAudioGain(el, sceneAudioGainRef.current, ref.assetId);
} catch {
// ignore
}
@@ -365,7 +395,7 @@ export function ControlApp() {
}
if (sceneAudioLoadRunRef.current !== runId || audioUnmountRef.current) {
try {
el.volume = 1;
applyAudioGain(el, sceneAudioGainRef.current, ref.assetId);
} catch {
// ignore
}
@@ -375,7 +405,7 @@ export function ControlApp() {
const tickIn = (now: number): void => {
if (sceneAudioLoadRunRef.current !== runId || audioUnmountRef.current) {
try {
el.volume = 1;
applyAudioGain(el, sceneAudioGainRef.current, ref.assetId);
} catch {
// ignore
}
@@ -383,7 +413,7 @@ export function ControlApp() {
}
const u = Math.min(1, (now - tIn0) / FADE_IN_MS);
try {
el.volume = u;
applyAudioGain(el, sceneAudioGainRef.current, ref.assetId, u);
} catch {
// ignore
}
@@ -411,10 +441,10 @@ export function ControlApp() {
campaignAudioMetaRef.current.clear();
setCampaignAudioStateTick((x) => x + 1);
for (const el of oldEls.values()) {
for (const [id, el] of oldEls) {
try {
el.pause();
el.volume = 1;
applyAudioGain(el, campaignAudioGainRef.current, id);
} catch {
// ignore
}
@@ -433,7 +463,8 @@ export function ControlApp() {
const el = new Audio(r.url);
el.loop = item.loop;
el.preload = 'auto';
el.volume = item.autoplay ? 0 : 1;
if (item.autoplay) el.volume = 0;
else applyAudioGain(el, campaignAudioGainRef.current, item.assetId);
campaignAudioMetaRef.current.set(item.assetId, { lastPlayError: null });
el.addEventListener('play', () => setCampaignAudioStateTick((x) => x + 1));
el.addEventListener('pause', () => setCampaignAudioStateTick((x) => x + 1));
@@ -460,7 +491,7 @@ export function ControlApp() {
});
setCampaignAudioStateTick((x) => x + 1);
try {
el.volume = 1;
applyAudioGain(el, campaignAudioGainRef.current, ref.assetId);
} catch {
// ignore
}
@@ -468,7 +499,7 @@ export function ControlApp() {
}
if (campaignAudioLoadRunRef.current !== runId || audioUnmountRef.current) {
try {
el.volume = 1;
applyAudioGain(el, campaignAudioGainRef.current, ref.assetId);
} catch {
// ignore
}
@@ -478,7 +509,7 @@ export function ControlApp() {
const tickIn = (now: number): void => {
if (campaignAudioLoadRunRef.current !== runId || audioUnmountRef.current) {
try {
el.volume = 1;
applyAudioGain(el, campaignAudioGainRef.current, ref.assetId);
} catch {
// ignore
}
@@ -486,7 +517,7 @@ export function ControlApp() {
}
const u = Math.min(1, (now - tIn0) / 550);
try {
el.volume = u;
applyAudioGain(el, campaignAudioGainRef.current, ref.assetId, u);
} catch {
// ignore
}
@@ -512,7 +543,7 @@ export function ControlApp() {
try {
// If a track was created with autoplay volume ramp but never started yet,
// ensure it is audible on resume.
if (el.volume === 0) el.volume = 1;
if (el.volume === 0) applyAudioGain(el, campaignAudioGainRef.current, assetId);
await el.play();
} catch {
// ignore; user can press play
@@ -547,7 +578,7 @@ export function ControlApp() {
if (!allowCampaignAudioRef.current || audioUnmountRef.current) return;
const u = Math.min(1, (now - tIn0) / 550);
try {
el.volume = u;
applyAudioGain(el, campaignAudioGainRef.current, ref.assetId, u);
} catch {
// ignore
}
@@ -578,29 +609,6 @@ export function ControlApp() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [allowCampaignAudio]);
const anyPlaying = useMemo(() => {
for (const el of sceneAudioElsRef.current.values()) {
if (!el.paused) return true;
}
for (const el of campaignAudioElsRef.current.values()) {
if (!el.paused) return true;
}
return false;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [campaignAudioStateTick, sceneAudioStateTick]);
useEffect(() => {
if (!anyPlaying) return;
let raf = 0;
const tick = () => {
setSceneAudioStateTick((x) => x + 1);
setCampaignAudioStateTick((x) => x + 1);
raf = window.requestAnimationFrame(tick);
};
raf = window.requestAnimationFrame(tick);
return () => window.cancelAnimationFrame(raf);
}, [anyPlaying]);
useEffect(() => {
const host = previewHostRef.current;
if (!host) return;
@@ -726,11 +734,147 @@ export function ControlApp() {
el.style.height = `${String(size)}px`;
}
function buildDraftEffectInstance(): EffectInstance | null {
const b = brushRef.current;
if (!b) return null;
const meta = draftMetaRef.current ?? { createdAtMs: Date.now(), seed: 12345 };
const { createdAtMs, seed } = meta;
const t = toolRef.current;
if (b.tool === 'fog' && b.points && b.points.length > 0) {
return {
id: '__draft__',
type: 'fog',
seed,
createdAtMs,
points: b.points,
radiusN: t.radiusN,
opacity: Math.max(0.05, Math.min(0.6, t.intensity * 0.7)),
lifetimeMs: null,
};
}
if (b.tool === 'fire' && b.points && b.points.length > 0) {
return {
id: '__draft__',
type: 'fire',
seed,
createdAtMs,
points: b.points,
radiusN: t.radiusN,
opacity: 1,
lifetimeMs: null,
};
}
if (b.tool === 'rain' && b.points && b.points.length > 0) {
return {
id: '__draft__',
type: 'rain',
seed,
createdAtMs,
points: b.points,
radiusN: t.radiusN,
opacity: Math.max(0.08, Math.min(0.65, t.intensity * 0.85)),
lifetimeMs: null,
};
}
if (b.tool === 'water' && b.points && b.points.length > 0) {
return {
id: '__draft__',
type: 'water',
seed,
createdAtMs,
points: b.points,
radiusN: t.radiusN,
opacity: Math.max(0.06, Math.min(0.55, t.intensity * 0.72)),
lifetimeMs: null,
};
}
if (b.tool === 'darkness' && b.points && b.points.length > 0) {
const last = b.points[b.points.length - 1];
if (last === undefined) return null;
return {
id: '__draft__',
type: 'darkness',
seed,
createdAtMs,
at: { x: last.x, y: last.y },
intensity: Math.max(0.8, Math.min(1.25, t.intensity * 1.15)),
lifetimeMs: darknessDraftLifeMsRef.current,
};
}
if (b.tool === 'lightning' && b.startN && b.points && b.points.length > 0) {
const last = b.points[b.points.length - 1];
if (last === undefined) return null;
return {
id: '__draft__',
type: 'lightning',
seed,
createdAtMs,
start: { x: last.x, y: 0 },
end: { x: last.x, y: last.y },
widthN: Math.max(0.014, t.radiusN * 1.15),
intensity: Math.max(1, Math.min(1.4, t.intensity * 1.45)),
lifetimeMs: LIGHTNING_EFFECT_MS,
};
}
if (b.tool === 'sunbeam' && b.startN && b.points && b.points.length > 0) {
const last = b.points[b.points.length - 1];
if (last === undefined) return null;
return {
id: '__draft__',
type: 'sunbeam',
seed,
createdAtMs,
start: { x: last.x, y: 0 },
end: { x: last.x, y: last.y },
widthN: Math.max(0.012, t.radiusN * 0.95),
intensity: Math.max(0.95, Math.min(1.25, t.intensity * 1.4)),
lifetimeMs: sunbeamDraftLifeMsRef.current,
};
}
if (b.tool === 'poisonCloud' && b.points && b.points.length > 0) {
const last = b.points[b.points.length - 1];
if (last === undefined) return null;
return {
id: '__draft__',
type: 'poisonCloud',
seed,
createdAtMs,
at: { x: last.x, y: last.y },
radiusN: Math.max(0.03, t.radiusN * 0.95),
intensity: Math.max(0.75, Math.min(1.2, t.intensity * 1.15)),
lifetimeMs: poisonDraftLifeMsRef.current,
};
}
if (b.tool === 'freeze' && b.points && b.points.length > 0) {
const last = b.points[b.points.length - 1];
if (last === undefined) return null;
return {
id: '__draft__',
type: 'freeze',
seed,
createdAtMs,
at: { x: last.x, y: last.y },
intensity: Math.max(0.8, Math.min(1.25, t.intensity * 1.15)),
lifetimeMs: freezeDraftLifeMsRef.current,
};
}
return null;
}
function pushDraftToPixi(): void {
effectsOverlayRef.current?.setDraft(buildDraftEffectInstance());
}
function clearDraftFromPixi(): void {
draftMetaRef.current = null;
effectsOverlayRef.current?.setDraft(null);
}
function scheduleDraftRepaint(): void {
if (draftPaintRafRef.current !== 0) return;
draftPaintRafRef.current = requestAnimationFrame(() => {
draftPaintRafRef.current = 0;
setDraftFxTick((x) => x + 1);
pushDraftToPixi();
const b = brushRef.current;
if (b?.tool === 'exploreBrush' && b.points) {
void sd.dispatch({
@@ -780,7 +924,7 @@ export function ControlApp() {
async function commitStroke(): Promise<void> {
if (isVideoPreviewScene) {
brushRef.current = null;
setDraftFxTick((x) => x + 1);
clearDraftFromPixi();
return;
}
if (!fxState) return;
@@ -1006,149 +1150,8 @@ export function ControlApp() {
playFreezeEffectSound();
}
brushRef.current = null;
setDraftFxTick((x) => x + 1);
clearDraftFromPixi();
}
const draftInstance = useMemo(() => {
const b = brushRef.current;
if (!b) return null;
const seed = 12345;
const createdAtMs = Date.now();
if (b.tool === 'fog' && b.points && b.points.length > 0) {
return {
id: '__draft__',
type: 'fog' as const,
seed,
createdAtMs,
points: b.points,
radiusN: tool.radiusN,
opacity: Math.max(0.05, Math.min(0.6, tool.intensity * 0.7)),
lifetimeMs: null,
};
}
if (b.tool === 'fire' && b.points && b.points.length > 0) {
return {
id: '__draft__',
type: 'fire' as const,
seed,
createdAtMs,
points: b.points,
radiusN: tool.radiusN,
opacity: 1,
lifetimeMs: null,
};
}
if (b.tool === 'rain' && b.points && b.points.length > 0) {
return {
id: '__draft__',
type: 'rain' as const,
seed,
createdAtMs,
points: b.points,
radiusN: tool.radiusN,
opacity: Math.max(0.08, Math.min(0.65, tool.intensity * 0.85)),
lifetimeMs: null,
};
}
if (b.tool === 'water' && b.points && b.points.length > 0) {
return {
id: '__draft__',
type: 'water' as const,
seed,
createdAtMs,
points: b.points,
radiusN: tool.radiusN,
opacity: Math.max(0.06, Math.min(0.55, tool.intensity * 0.72)),
lifetimeMs: null,
};
}
if (b.tool === 'darkness' && b.points && b.points.length > 0) {
const last = b.points[b.points.length - 1];
if (last === undefined) return null;
return {
id: '__draft__',
type: 'darkness' as const,
seed,
createdAtMs,
at: { x: last.x, y: last.y },
intensity: Math.max(0.8, Math.min(1.25, tool.intensity * 1.15)),
lifetimeMs: darknessDraftLifeMs,
};
}
if (b.tool === 'lightning' && b.startN && b.points && b.points.length > 0) {
const last = b.points[b.points.length - 1];
if (last === undefined) return null;
return {
id: '__draft__',
type: 'lightning' as const,
seed,
createdAtMs,
start: { x: last.x, y: 0 },
end: { x: last.x, y: last.y },
widthN: Math.max(0.014, tool.radiusN * 1.15),
intensity: Math.max(1, Math.min(1.4, tool.intensity * 1.45)),
lifetimeMs: LIGHTNING_EFFECT_MS,
};
}
if (b.tool === 'sunbeam' && b.startN && b.points && b.points.length > 0) {
const last = b.points[b.points.length - 1];
if (last === undefined) return null;
return {
id: '__draft__',
type: 'sunbeam' as const,
seed,
createdAtMs,
start: { x: last.x, y: 0 },
end: { x: last.x, y: last.y },
widthN: Math.max(0.012, tool.radiusN * 0.95),
intensity: Math.max(0.95, Math.min(1.25, tool.intensity * 1.4)),
lifetimeMs: sunbeamDraftLifeMs,
};
}
if (b.tool === 'poisonCloud' && b.points && b.points.length > 0) {
const last = b.points[b.points.length - 1];
if (last === undefined) return null;
return {
id: '__draft__',
type: 'poisonCloud' as const,
seed,
createdAtMs,
at: { x: last.x, y: last.y },
radiusN: Math.max(0.03, tool.radiusN * 0.95),
intensity: Math.max(0.75, Math.min(1.2, tool.intensity * 1.15)),
lifetimeMs: poisonDraftLifeMs,
};
}
if (b.tool === 'freeze' && b.points && b.points.length > 0) {
const last = b.points[b.points.length - 1];
if (last === undefined) return null;
return {
id: '__draft__',
type: 'freeze' as const,
seed,
createdAtMs,
at: { x: last.x, y: last.y },
intensity: Math.max(0.8, Math.min(1.25, tool.intensity * 1.15)),
lifetimeMs: freezeDraftLifeMs,
};
}
return null;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
draftFxTick,
darknessDraftLifeMs,
freezeDraftLifeMs,
poisonDraftLifeMs,
sunbeamDraftLifeMs,
tool.intensity,
tool.radiusN,
tool.tool,
]);
const fxMergedState = useMemo(() => {
if (!fxState) return null;
if (!draftInstance) return fxState;
return { ...fxState, instances: [...fxState.instances, draftInstance] };
}, [draftInstance, fxState]);
return (
<div className={styles.page}>
@@ -1471,7 +1474,8 @@ export function ControlApp() {
{!isVideoPreviewScene ? (
<>
<PixiEffectsOverlay
state={fxMergedState}
ref={effectsOverlayRef}
state={fxState}
style={{ zIndex: 1 }}
viewport={
previewContentRect
@@ -1518,6 +1522,7 @@ export function ControlApp() {
tryEraseActionEffect(p);
return;
}
draftMetaRef.current = { createdAtMs: Date.now(), seed: 12345 };
brushRef.current = {
tool: tool.tool,
startN: p,
@@ -1532,7 +1537,7 @@ export function ControlApp() {
},
});
}
setDraftFxTick((x) => x + 1);
pushDraftToPixi();
}}
onPointerMove={(e) => {
const p = toNPoint(e);
@@ -1579,7 +1584,7 @@ export function ControlApp() {
void sd.dispatch({ kind: 'draft.set', draft: null });
}
brushRef.current = null;
setDraftFxTick((x) => x + 1);
clearDraftFromPixi();
}}
/>
</>
@@ -1589,33 +1594,11 @@ export function ControlApp() {
session?.project && materialsOverlay?.activeMaterialId
? (session.project.materials ?? []).find((m) => m.id === materialsOverlay.activeMaterialId)
: undefined;
if (!activeMaterial) return null;
return (
<MaterialOverlay
assetId={activeMaterial.assetId}
layout={materialsOverlay?.layout ?? DEFAULT_MATERIALS_OVERLAY_LAYOUT}
editable
zoomTool={materialsOverlay?.zoomTool ?? null}
showClose
closeLabel={t('materials.closeOverlay')}
rotateLabel={t('materials.rotateOverlay')}
onClose={() => {
void materialsApi.dispatch({ kind: 'hide' });
}}
onLayoutChange={(layout) => {
void materialsApi.dispatch({ kind: 'layout.set', layout });
}}
onZoomAt={(nx, ny) => {
void materialsApi.dispatch({ kind: 'zoomAt', nx, ny });
}}
/>
);
})()}
{(() => {
const project = session?.project;
const activeIds = npcsOverlay?.activeNpcIds ?? [];
if (!project || activeIds.length === 0) return null;
const items = activeIds
const npcItems =
project && activeIds.length > 0
? activeIds
.map((id) => {
const npc = (project.npcs ?? []).find((n) => n.id === id);
if (!npc) return null;
@@ -1625,22 +1608,74 @@ export function ControlApp() {
layout: npcsOverlay?.layouts[id] ?? DEFAULT_NPCS_OVERLAY_LAYOUT,
};
})
.filter((x): x is NonNullable<typeof x> => x !== null);
if (items.length === 0) return null;
return (
<NpcsSceneOverlay
items={items}
editable
showClose
closeLabel={t('npcs.closeOverlay')}
rotateLabel={t('npcs.rotateOverlay')}
onClose={() => {
.filter((x): x is NonNullable<typeof x> => x !== null)
: [];
const showMaterial = Boolean(activeMaterial);
const showNpcs = npcItems.length > 0;
if (!showMaterial && !showNpcs) return null;
const closes = [
...(showMaterial
? [
{
key: 'materials',
label: t('materials.closeOverlay'),
onClose: () => {
void materialsApi.dispatch({ kind: 'hide' });
},
},
]
: []),
...(showNpcs
? [
{
key: 'npcs',
label: t('npcs.closeOverlay'),
onClose: () => {
void npcsApi.dispatch({ kind: 'hide' });
},
},
]
: []),
];
const materialsZoom = materialsOverlay?.zoomTool ?? null;
return (
<SceneOverlayHost
active
zoomTool={materialsZoom}
{...(materialsZoom
? {
onZoomAt: (nx: number, ny: number) => {
void materialsApi.dispatch({ kind: 'zoomAt', nx, ny });
},
}
: {})}
closes={closes}
>
{showMaterial && activeMaterial ? (
<MaterialOverlay
embedded
assetId={activeMaterial.assetId}
layout={materialsOverlay?.layout ?? DEFAULT_MATERIALS_OVERLAY_LAYOUT}
editable
zoomTool={materialsZoom}
rotateLabel={t('materials.rotateOverlay')}
onLayoutChange={(layout) => {
void materialsApi.dispatch({ kind: 'layout.set', layout });
}}
/>
) : null}
{showNpcs ? (
<NpcsSceneOverlay
embedded
items={npcItems}
editable
rotateLabel={t('npcs.rotateOverlay')}
onLayoutChange={(npcId, layout) => {
void npcsApi.dispatch({ kind: 'layout.set', npcId, layout });
}}
/>
) : null}
</SceneOverlayHost>
);
})()}
</div>
@@ -1708,63 +1743,39 @@ export function ControlApp() {
{sceneAudios.map(({ ref, asset }) => {
const el = sceneAudioElsRef.current.get(ref.assetId) ?? null;
const st = audioStatus('scene', ref.assetId);
const dur = el?.duration && Number.isFinite(el.duration) ? el.duration : 0;
const cur = el?.currentTime && Number.isFinite(el.currentTime) ? el.currentTime : 0;
const pct = dur > 0 ? Math.max(0, Math.min(1, cur / dur)) : 0;
return (
<div key={ref.assetId} className={styles.audioCard}>
<div className={styles.audioMeta}>
<div className={styles.audioName}>{asset.originalName}</div>
<div className={styles.audioBadges}>
<div>{ref.autoplay ? t('control.modeAuto') : t('control.modeManual')}</div>
<div>{ref.loop ? t('control.loop') : t('control.once')}</div>
<div title={st.detail}>{st.label}</div>
</div>
<div className={styles.spacer10} />
<div
role="slider"
aria-valuemin={0}
aria-valuemax={dur > 0 ? Math.round(dur) : 0}
aria-valuenow={Math.round(cur)}
tabIndex={0}
onKeyDown={(e) => {
if (!el) return;
if (!dur) return;
if (e.key === 'ArrowLeft') el.currentTime = Math.max(0, el.currentTime - 5);
if (e.key === 'ArrowRight') el.currentTime = Math.min(dur, el.currentTime + 5);
setSceneAudioStateTick((x) => x + 1);
}}
onClick={(e) => {
if (!el) return;
if (!dur) return;
const rect = (e.currentTarget as HTMLDivElement).getBoundingClientRect();
const next = (e.clientX - rect.left) / rect.width;
el.currentTime = Math.max(0, Math.min(dur, next * dur));
setSceneAudioStateTick((x) => x + 1);
}}
className={[
styles.audioScrub,
dur > 0 ? styles.audioScrubPointer : styles.audioScrubDefault,
].join(' ')}
title={dur > 0 ? t('control.scrubSeek') : t('control.durationUnknown')}
>
<div
className={styles.scrubFill}
style={{ width: `${String(Math.round(pct * 100))}%` }}
/>
</div>
<div className={styles.timeRow}>
<div>{formatTime(cur)}</div>
<div>{dur ? formatTime(dur) : '—:—'}</div>
</div>
</div>
<div className={styles.audioTransport}>
<Button
variant="primary"
onClick={() => {
<ControlAudioCard
key={ref.assetId}
assetId={ref.assetId}
name={asset.originalName}
autoplay={ref.autoplay}
loop={ref.loop}
statusLabel={st.label}
{...(st.detail ? { statusDetail: st.detail } : {})}
audioEl={el}
initialGain={readAudioGain(sceneAudioGainRef.current, ref.assetId)}
gainMap={sceneAudioGainRef.current}
playTitle={t('control.transportPlay')}
playLabel={t('control.transportPlay')}
pauseLabel={t('control.transportPause')}
stopLabel={t('control.transportStop')}
volumeLabel={t('control.volume')}
modeAutoLabel={t('control.modeAuto')}
modeManualLabel={t('control.modeManual')}
loopLabel={t('control.loop')}
onceLabel={t('control.once')}
scrubSeekLabel={t('control.scrubSeek')}
durationUnknownLabel={t('control.durationUnknown')}
onStatusChange={() => setSceneAudioStateTick((x) => x + 1)}
onPlay={() => {
if (!el) return;
const m = sceneAudioMetaRef.current.get(ref.assetId) ?? { lastPlayError: null };
sceneAudioMetaRef.current.set(ref.assetId, { ...m, lastPlayError: null });
try {
applyAudioGain(el, sceneAudioGainRef.current, ref.assetId);
} catch {
// ignore
}
void el.play().catch(() => {
const mm =
sceneAudioMetaRef.current.get(ref.assetId) ??
@@ -1776,29 +1787,17 @@ export function ControlApp() {
setSceneAudioStateTick((x) => x + 1);
});
}}
>
</Button>
<Button
onClick={() => {
onPause={() => {
if (!el) return;
el.pause();
}}
>
</Button>
<Button
onClick={() => {
onStop={() => {
if (!el) return;
el.pause();
el.currentTime = 0;
setSceneAudioStateTick((x) => x + 1);
}}
>
</Button>
</div>
</div>
/>
);
})}
</div>
@@ -1814,71 +1813,47 @@ export function ControlApp() {
{campaignAudios.map(({ ref, asset }) => {
const el = campaignAudioElsRef.current.get(ref.assetId) ?? null;
const st = audioStatus('campaign', ref.assetId);
const dur = el?.duration && Number.isFinite(el.duration) ? el.duration : 0;
const cur = el?.currentTime && Number.isFinite(el.currentTime) ? el.currentTime : 0;
const pct = dur > 0 ? Math.max(0, Math.min(1, cur / dur)) : 0;
return (
<div key={ref.assetId} className={styles.audioCard}>
<div className={styles.audioMeta}>
<div className={styles.audioName}>{asset.originalName}</div>
<div className={styles.audioBadges}>
<div>{ref.autoplay ? t('control.modeAuto') : t('control.modeManual')}</div>
<div>{ref.loop ? t('control.loop') : t('control.once')}</div>
<div title={st.detail}>{st.label}</div>
{!allowCampaignAudio ? (
<ControlAudioCard
key={ref.assetId}
assetId={ref.assetId}
name={asset.originalName}
autoplay={ref.autoplay}
loop={ref.loop}
statusLabel={st.label}
{...(st.detail ? { statusDetail: st.detail } : {})}
{...(!allowCampaignAudio
? {
extraBadge: (
<div title={t('control.pauseSceneMusicTitle')}>{t('control.pauseSceneMusic')}</div>
) : null}
</div>
<div className={styles.spacer10} />
<div
role="slider"
aria-valuemin={0}
aria-valuemax={dur > 0 ? Math.round(dur) : 0}
aria-valuenow={Math.round(cur)}
tabIndex={0}
onKeyDown={(e) => {
if (!el) return;
if (!dur) return;
if (e.key === 'ArrowLeft') el.currentTime = Math.max(0, el.currentTime - 5);
if (e.key === 'ArrowRight') el.currentTime = Math.min(dur, el.currentTime + 5);
setCampaignAudioStateTick((x) => x + 1);
}}
onClick={(e) => {
if (!el) return;
if (!dur) return;
const rect = (e.currentTarget as HTMLDivElement).getBoundingClientRect();
const next = (e.clientX - rect.left) / rect.width;
el.currentTime = Math.max(0, Math.min(dur, next * dur));
setCampaignAudioStateTick((x) => x + 1);
}}
className={[
styles.audioScrub,
dur > 0 ? styles.audioScrubPointer : styles.audioScrubDefault,
].join(' ')}
title={dur > 0 ? t('control.scrubSeek') : t('control.durationUnknown')}
>
<div
className={styles.scrubFill}
style={{ width: `${String(Math.round(pct * 100))}%` }}
/>
</div>
<div className={styles.timeRow}>
<div>{formatTime(cur)}</div>
<div>{dur ? formatTime(dur) : '—:—'}</div>
</div>
</div>
<div className={styles.audioTransport}>
<Button
variant="primary"
title={!allowCampaignAudio ? t('control.pauseCampaignTitle') : undefined}
onClick={() => {
),
}
: {})}
audioEl={el}
initialGain={readAudioGain(campaignAudioGainRef.current, ref.assetId)}
gainMap={campaignAudioGainRef.current}
playTitle={
!allowCampaignAudio ? t('control.pauseCampaignTitle') : t('control.transportPlay')
}
playLabel={t('control.transportPlay')}
pauseLabel={t('control.transportPause')}
stopLabel={t('control.transportStop')}
volumeLabel={t('control.volume')}
modeAutoLabel={t('control.modeAuto')}
modeManualLabel={t('control.modeManual')}
loopLabel={t('control.loop')}
onceLabel={t('control.once')}
scrubSeekLabel={t('control.scrubSeek')}
durationUnknownLabel={t('control.durationUnknown')}
onStatusChange={() => setCampaignAudioStateTick((x) => x + 1)}
onPlay={() => {
if (!el) return;
const m = campaignAudioMetaRef.current.get(ref.assetId) ?? { lastPlayError: null };
campaignAudioMetaRef.current.set(ref.assetId, { ...m, lastPlayError: null });
// If this track was created for autoplay but autoplay was blocked (e.g. scene music),
// it might still be at volume 0. Ensure manual play is audible.
try {
if (el.volume === 0) el.volume = 1;
if (el.volume === 0) {
applyAudioGain(el, campaignAudioGainRef.current, ref.assetId);
}
} catch {
// ignore
}
@@ -1893,29 +1868,17 @@ export function ControlApp() {
setCampaignAudioStateTick((x) => x + 1);
});
}}
>
</Button>
<Button
onClick={() => {
onPause={() => {
if (!el) return;
el.pause();
}}
>
</Button>
<Button
onClick={() => {
onStop={() => {
if (!el) return;
el.pause();
el.currentTime = 0;
setCampaignAudioStateTick((x) => x + 1);
}}
>
</Button>
</div>
</div>
/>
);
})}
</div>
+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);
});
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', () => {
const src = readControlApp();
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.transportPause': 'Пауза',
'control.transportStop': 'Стоп',
'control.volume': 'Громкость',
},
en: {
'common.close': 'Close',
@@ -1125,6 +1126,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'control.transportPlay': 'Play',
'control.transportPause': 'Pause',
'control.transportStop': 'Stop',
'control.volume': 'Volume',
},
};
@@ -19,6 +19,7 @@ import type {
SceneId,
} from '../../../shared/types';
import { getDndApi } from '../../shared/dndApi';
import { invalidateAssetUrlCache } from '../../shared/useAssetImageUrl';
type ProjectSummary = { id: ProjectId; name: string; updatedAt: string; fileName: string };
@@ -277,6 +278,8 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
projectDataEpochRef.current += 1;
const epoch = projectDataEpochRef.current;
openInFlightRef.current = null;
// URL ассетов зависят от открытого проекта — сбрасываем renderer-кэш.
invalidateAssetUrlCache();
const job = (async () => {
setState((s) => ({ ...s, openingProjectId: id }));
@@ -308,6 +311,7 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
const closeProject = async () => {
projectDataEpochRef.current += 1;
openInFlightRef.current = null;
invalidateAssetUrlCache();
try {
await api.invoke(ipcChannels.project.close, {});
} finally {
+5 -1
View File
@@ -12,6 +12,7 @@ import { MaterialOverlay } from './materials/MaterialOverlay';
import { useMaterialsOverlayState } from './materials/useMaterialsOverlayState';
import { NpcsSceneOverlay } from './npcs/NpcsSceneOverlay';
import { useNpcsOverlayState } from './npcs/useNpcsOverlayState';
import { SceneOverlayHost } from './sceneOverlay/SceneOverlayHost';
import styles from './PresentationView.module.css';
import { RotatedImage } from './RotatedImage';
import { useAssetUrl } from './useAssetImageUrl';
@@ -164,13 +165,16 @@ export function PresentationView({
{showEffects && scene?.previewAssetType === 'image' && scene.darkenScene && contentRect ? (
<SceneDarknessOverlay state={sdState} overlayAlpha={1} viewport={contentRect} />
) : null}
<SceneOverlayHost active={Boolean(activeMaterial) || activeNpcItems.length > 0}>
{activeMaterial ? (
<MaterialOverlay
embedded
assetId={activeMaterial.assetId}
layout={materialsOverlay?.layout ?? DEFAULT_MATERIALS_OVERLAY_LAYOUT}
/>
) : null}
{activeNpcItems.length > 0 ? <NpcsSceneOverlay items={activeNpcItems} /> : null}
{activeNpcItems.length > 0 ? <NpcsSceneOverlay embedded items={activeNpcItems} /> : null}
</SceneOverlayHost>
{showTitle ? (
<div className={styles.titleWrap}>
<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');
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';
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_ASPECT = 420 / 473;
const LIGHTNING_VFX_STRIKE_MS = 320;
@@ -52,18 +183,81 @@ type Props = {
* - Pixi `Application` — это WebGL-рендерер + тикер.
* - Мы держим один `Application` на компонент, и при изменении `state` просто перерисовываем сцену.
* - Вариант 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 appRef = useRef<any>(null);
const rootRef = useRef<any>(null);
const pixiRef = useRef<any>(null);
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 timeOffsetRef = useRef(0);
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 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, визуально ок для оверлея эффектов. */
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) {
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);
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/дрейф/фликер).
// В idle (нет instances/draft) ticker останавливается — см. syncTickerForState.
app.ticker.add(() => {
const s = stateRef.current;
if (!s) return;
if (!s || s.instances.length === 0) return;
const nowMs = Date.now() + timeOffsetRef.current;
animateNodes(pixi, nodesRef.current, s, nowMs, sizeRef.current, viewportRef.current);
// Лёгкое “потряхивание” сцены в момент удара молнии.
// Делаем через смещение корневого контейнера, чтобы не вмешиваться в рендерер/камера-логику.
const root = rootRef.current;
if (root) {
const rootNode = rootRef.current;
if (rootNode) {
const { x, y } = computeSceneShake(s, nowMs, sizeRef.current);
root.x = x;
root.y = y;
rootNode.x = x;
rootNode.y = y;
}
});
syncTickerForState(stateRef.current);
cleanup = () => ro.disconnect();
} catch (e) {
@@ -181,16 +371,7 @@ export function PixiEffectsOverlay({ state, interactive = false, style, viewport
}, [interactive]);
useEffect(() => {
const app = appRef.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);
applyMergedState(state, draftRef.current);
}, [state]);
useEffect(() => {
@@ -207,7 +388,7 @@ export function PixiEffectsOverlay({ state, interactive = false, style, viewport
const hostClass = [styles.host, interactive ? styles.hostInteractive : styles.hostPassthrough].join(' ');
return <div ref={hostRef} className={hostClass} style={style} />;
}
});
function syncNodes(
pixi: any,
@@ -236,6 +417,19 @@ function syncNodes(
const sig = instanceSig(inst, viewport);
const existing = nodes.get(inst.id);
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) {
const fx = (existing as any).__fx;
fx?.video?.pause?.();
@@ -12,6 +12,15 @@
pointer-events: none;
}
/** Общий host: клики проходят сквозь dim к сцене; кадры/кнопки ловят сами. */
.hostHitThrough {
pointer-events: none;
}
.captureZoom {
pointer-events: auto;
}
.cursorZoomIn {
cursor: zoom-in;
}
@@ -89,11 +98,22 @@
cursor: nwse-resize;
}
.close {
.closeStack {
position: absolute;
top: 14px;
right: 14px;
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;
height: 36px;
border: none;
@@ -105,12 +125,20 @@
cursor: pointer;
display: grid;
place-items: center;
pointer-events: auto;
}
.close:hover {
background: rgba(24, 24, 32, 0.9);
}
/** Standalone-оверлей (без host): одна кнопка в углу. */
.root > .close {
position: absolute;
top: 14px;
right: 14px;
}
.frameRotate {
position: absolute;
left: 50%;
@@ -2,6 +2,7 @@ import React, { useEffect, useRef, useState } from 'react';
import type { AssetId, MaterialsOverlayLayout, MaterialsZoomTool, NpcsZoomTool } from '../../../shared/types';
import { DEFAULT_MATERIALS_OVERLAY_LAYOUT } from '../../../shared/types';
import { useSceneOverlayView } from '../sceneOverlay/SceneOverlayViewContext';
import { useAssetUrl } from '../useAssetImageUrl';
import styles from './MaterialOverlay.module.css';
@@ -19,6 +20,8 @@ type MaterialOverlayProps = {
rotateLabel?: string;
onLayoutChange?: (layout: MaterialsOverlayLayout) => void;
onZoomAt?: (nx: number, ny: number) => void;
/** Без собственного root/dim — внутри `SceneOverlayHost`. */
embedded?: boolean;
};
function RotateIcon() {
@@ -95,11 +98,21 @@ export function MaterialOverlay({
rotateLabel = 'Rotate',
onLayoutChange,
onZoomAt,
embedded = false,
}: MaterialOverlayProps) {
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 [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<
| { mode: 'move'; startX: number; startY: number; origin: MaterialsOverlayLayout }
| {
@@ -124,14 +137,33 @@ export function MaterialOverlay({
>(null);
useEffect(() => {
const el = rootRef.current;
if (embedded) return;
const el = localRootRef.current;
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();
const ro = new ResizeObserver(sync);
ro.observe(el);
return () => ro.disconnect();
}, [url]);
return () => {
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;
@@ -139,7 +171,7 @@ export function MaterialOverlay({
const zoomCursor =
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 base = nextBaseSize(view.w, view.h, natural.w, natural.h);
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 drag = dragRef.current;
if (!drag || !onLayoutChange) return;
@@ -174,7 +218,7 @@ export function MaterialOverlay({
if (drag.mode === 'rotate') {
const angle = pointerAngleDeg(drag.centerX, drag.centerY, e.clientX, e.clientY);
const delta = shortestAngleDelta(drag.startPointerAngle, angle);
onLayoutChange({
publishDraft({
...drag.origin,
rotationDeg: drag.origin.rotationDeg + delta,
});
@@ -188,7 +232,7 @@ export function MaterialOverlay({
if (drag.mode === 'move') {
const dx = (e.clientX - drag.startX) / Math.max(1, r.width);
const dy = (e.clientY - drag.startY) / Math.max(1, r.height);
onLayoutChange({
publishDraft({
...drag.origin,
cx: drag.origin.cx + dx,
cy: drag.origin.cy + dy,
@@ -247,7 +291,7 @@ export function MaterialOverlay({
const localCenterY = nextTop + hh / 2;
const screenOffset = localToScreenOffset(localCenterX, localCenterY, origin.rotationDeg ?? 0);
onLayoutChange({
publishDraft({
...origin,
cx: origin.cx + screenOffset.x / Math.max(1, viewW),
cy: origin.cy + screenOffset.y / Math.max(1, viewH),
@@ -259,6 +303,15 @@ export function MaterialOverlay({
dragRef.current = null;
window.removeEventListener('pointermove', onPointerMove);
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) => {
@@ -306,26 +359,10 @@ export function MaterialOverlay({
window.addEventListener('pointerup', endDrag);
};
return (
const frame = (
<div
ref={rootRef}
className={[styles.root, interactive ? styles.interactive : styles.passive, zoomCursor]
.filter(Boolean)
.join(' ')}
role="dialog"
aria-modal="true"
onClick={(e) => {
if (!zoomTool || !onZoomAt) return;
e.stopPropagation();
const { nx, ny } = toNorm(e.clientX, e.clientY);
onZoomAt(nx, ny);
}}
>
<div className={styles.dim} />
<div
className={[styles.frame, editable && !zoomTool ? styles.frameEditable : '']
.filter(Boolean)
.join(' ')}
className={[styles.frame, editable && !zoomTool ? styles.frameEditable : ''].filter(Boolean).join(' ')}
data-overlay-kind="material"
style={{
left,
top,
@@ -377,6 +414,29 @@ export function MaterialOverlay({
</button>
) : null}
</div>
);
if (embedded) {
return frame;
}
return (
<div
ref={localRootRef}
className={[styles.root, interactive ? styles.interactive : styles.passive, zoomCursor]
.filter(Boolean)
.join(' ')}
role="dialog"
aria-modal="true"
onClick={(e) => {
if (!zoomTool || !onZoomAt) return;
e.stopPropagation();
const { nx, ny } = toNorm(e.clientX, e.clientY);
onZoomAt(nx, ny);
}}
>
<div className={styles.dim} />
{frame}
{showClose ? (
<button
type="button"
+87 -28
View File
@@ -3,6 +3,7 @@ import React, { useEffect, useRef, useState } from 'react';
import type { AssetId, NpcId, NpcsOverlayLayout, NpcsZoomTool } from '../../../shared/types';
import { DEFAULT_NPCS_OVERLAY_LAYOUT } from '../../../shared/types';
import styles from '../materials/MaterialOverlay.module.css';
import { useSceneOverlayView } from '../sceneOverlay/SceneOverlayViewContext';
import { useAssetUrl } from '../useAssetImageUrl';
type Corner = 'nw' | 'ne' | 'sw' | 'se';
@@ -23,6 +24,8 @@ type NpcsSceneOverlayProps = {
rotateLabel?: string;
onLayoutChange?: (npcId: NpcId, layout: NpcsOverlayLayout) => void;
onZoomAt?: (npcId: NpcId | undefined, nx: number, ny: number) => void;
/** Без собственного root/dim — внутри `SceneOverlayHost`. */
embedded?: boolean;
};
function RotateIcon() {
@@ -107,6 +110,11 @@ function NpcAvatarFrame({
}) {
const url = useAssetUrl(item.assetId);
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<
| { mode: 'move'; startX: number; startY: number; origin: NpcsOverlayLayout }
| {
@@ -130,9 +138,15 @@ function NpcAvatarFrame({
| null
>(null);
useEffect(() => {
if (dragRef.current) return;
setDraftLayout(null);
pendingLayoutRef.current = null;
}, [item.layout]);
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 base = nextBaseSize(view.w, view.h, natural.w, natural.h);
const w = base.w * layout.scale;
@@ -140,6 +154,18 @@ function NpcAvatarFrame({
const left = layout.cx * view.w - w / 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 drag = dragRef.current;
if (!drag || !onLayoutChange) return;
@@ -147,7 +173,7 @@ function NpcAvatarFrame({
if (drag.mode === 'rotate') {
const angle = pointerAngleDeg(drag.centerX, drag.centerY, e.clientX, e.clientY);
const delta = shortestAngleDelta(drag.startPointerAngle, angle);
onLayoutChange(item.npcId, {
publishDraft({
...drag.origin,
rotationDeg: drag.origin.rotationDeg + delta,
});
@@ -161,7 +187,7 @@ function NpcAvatarFrame({
if (drag.mode === 'move') {
const dx = (e.clientX - drag.startX) / Math.max(1, r.width);
const dy = (e.clientY - drag.startY) / Math.max(1, r.height);
onLayoutChange(item.npcId, {
publishDraft({
...drag.origin,
cx: drag.origin.cx + dx,
cy: drag.origin.cy + dy,
@@ -220,7 +246,7 @@ function NpcAvatarFrame({
const localCenterY = nextTop + hh / 2;
const screenOffset = localToScreenOffset(localCenterX, localCenterY, origin.rotationDeg ?? 0);
onLayoutChange(item.npcId, {
publishDraft({
...origin,
cx: origin.cx + screenOffset.x / Math.max(1, viewW),
cy: origin.cy + screenOffset.y / Math.max(1, viewH),
@@ -232,6 +258,15 @@ function NpcAvatarFrame({
dragRef.current = null;
window.removeEventListener('pointermove', onPointerMove);
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 = () => {
@@ -288,6 +323,7 @@ function NpcAvatarFrame({
<div
className={[styles.frame, editable && !zoomTool ? styles.frameEditable : ''].filter(Boolean).join(' ')}
data-npc-id={item.npcId}
data-overlay-kind="npc"
style={{
left,
top,
@@ -352,19 +388,36 @@ export function NpcsSceneOverlay({
rotateLabel = 'Rotate',
onLayoutChange,
onZoomAt,
embedded = false,
}: NpcsSceneOverlayProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const [view, setView] = useState({ w: 1, h: 1 });
const host = useSceneOverlayView();
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(() => {
const el = rootRef.current;
if (embedded) return;
const el = localRootRef.current;
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();
const ro = new ResizeObserver(sync);
ro.observe(el);
return () => ro.disconnect();
}, [items.length]);
return () => {
ro.disconnect();
if (raf !== 0) window.cancelAnimationFrame(raf);
};
}, [embedded, items.length]);
if (items.length === 0) return null;
@@ -389,23 +442,7 @@ export function NpcsSceneOverlay({
return raw ? (raw as NpcId) : undefined;
};
return (
<div
ref={rootRef}
className={[styles.root, interactive ? styles.interactive : styles.passive, zoomCursor]
.filter(Boolean)
.join(' ')}
role="dialog"
aria-modal="true"
onClick={(e) => {
if (!zoomTool || !onZoomAt) return;
e.stopPropagation();
const { nx, ny } = toNorm(e.clientX, e.clientY);
onZoomAt(npcIdFromTarget(e.target), nx, ny);
}}
>
<div className={styles.dim} />
{items.map((item) =>
const frames = items.map((item) =>
onLayoutChange ? (
<NpcAvatarFrame
key={item.npcId}
@@ -428,7 +465,29 @@ export function NpcsSceneOverlay({
rotateLabel={rotateLabel}
/>
),
)}
);
if (embedded) {
return <>{frames}</>;
}
return (
<div
ref={localRootRef}
className={[styles.root, interactive ? styles.interactive : styles.passive, zoomCursor]
.filter(Boolean)
.join(' ')}
role="dialog"
aria-modal="true"
onClick={(e) => {
if (!zoomTool || !onZoomAt) return;
e.stopPropagation();
const { nx, ny } = toNorm(e.clientX, e.clientY);
onZoomAt(npcIdFromTarget(e.target), nx, ny);
}}
>
<div className={styles.dim} />
{frames}
{showClose ? (
<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 { ipcChannels } from '../../shared/ipc/contracts';
import type { AssetId } from '../../shared/types';
import type { AssetId, ProjectId } from '../../shared/types';
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 {
ensureSessionProjectInvalidation();
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(() => {
if (id === null) {
setEntry(null);
return undefined;
}
const hit = peekAssetUrlCache(id);
if (hit !== undefined) {
setEntry({ assetId: id, url: hit });
return undefined;
}
let cancelled = false;
void getDndApi()
.invoke(ipcChannels.project.assetFileUrl, { assetId: id })
.then((r) => {
urlCache.set(id, r.url);
if (!cancelled) setEntry({ assetId: id, url: r.url });
});
return () => {
cancelled = true;
};
}, [id]);
}, [id, epoch]);
if (id === null) {
return null;
+1 -1
View File
@@ -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/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:write": "prettier . --write",
"postinstall": "patch-package",