feat(npcs): inspect cards, Show overlay, and optional external link
Clicking an NPC tile opens description cards (multi-select accordion); Show puts avatars on screen. Optional URL opens a webview tab. Avatar pan in the editor commits on pointer up. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -11,9 +11,12 @@ void test('PxiEffectsOverlay: canvas не перехватывает указа
|
||||
assert.ok(src.includes("app.canvas.style.pointerEvents = interactive ? 'auto' : 'none'"));
|
||||
});
|
||||
|
||||
void test('PxiEffectsOverlay: ограничение FPS тикера для нагрузки', () => {
|
||||
void test('PxiEffectsOverlay: lazy WebGL until effects need it', () => {
|
||||
const src = fs.readFileSync(path.join(here, 'PxiEffectsOverlay.tsx'), 'utf8');
|
||||
assert.ok(src.includes('app.ticker.maxFPS'));
|
||||
assert.ok(src.includes('effectsNeedWebGl'));
|
||||
assert.ok(src.includes('gpuBoot'));
|
||||
assert.match(src, /canvas\.style\.display = 'none'/);
|
||||
assert.match(src, /const dpr = useMemo\(\(\) => 1/);
|
||||
});
|
||||
|
||||
void test('PxiEffectsOverlay: imperative setDraft для кисти без React state', () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { forwardRef, useEffect, useImperativeHandle, useMemo, useRef } from 'react';
|
||||
import React, { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import type {
|
||||
EffectInstance,
|
||||
@@ -140,6 +140,18 @@ function effectsHaveWork(state: EffectsState | null): boolean {
|
||||
return Boolean(state && state.instances.length > 0);
|
||||
}
|
||||
|
||||
/** WebGL не поднимаем, пока нет полевых/action-эффектов — иначе видео на Retina/M1 композитится через лишний слой. */
|
||||
function effectsNeedWebGl(state: EffectsState | null, draft: EffectInstance | null): boolean {
|
||||
const merged = mergeDraftState(state, draft);
|
||||
if (effectsHaveWork(merged)) return true;
|
||||
const tool = merged?.tool.tool;
|
||||
if (!tool || tool === 'none' || tool === 'darkness' || tool === 'exploreBrush' || tool === 'closeBrush') {
|
||||
return false;
|
||||
}
|
||||
if (tool === 'eraser') return effectsHaveWork(state);
|
||||
return vfxPacksForTool(tool).length > 0 || tool === 'freeze';
|
||||
}
|
||||
|
||||
const LIGHTNING_VFX_FRAME_COUNT = 19;
|
||||
const LIGHTNING_VFX_FRAME_ASPECT = 420 / 473;
|
||||
const LIGHTNING_VFX_STRIKE_MS = 320;
|
||||
@@ -208,6 +220,9 @@ export const PixiEffectsOverlay = forwardRef<PixiEffectsOverlayHandle, Props>(fu
|
||||
const viewportProvidedRef = useRef(false);
|
||||
/** null = ещё не синхронизировали с Pixi (ticker по умолчанию бежит). */
|
||||
const tickerWantedRef = useRef<boolean | null>(null);
|
||||
const [gpuBoot, setGpuBoot] = useState(false);
|
||||
const setGpuBootRef = useRef(setGpuBoot);
|
||||
setGpuBootRef.current = setGpuBoot;
|
||||
|
||||
const syncTickerForState = (merged: EffectsState | null): void => {
|
||||
const app = appRef.current;
|
||||
@@ -217,6 +232,10 @@ export const PixiEffectsOverlay = forwardRef<PixiEffectsOverlayHandle, Props>(fu
|
||||
tickerWantedRef.current = want;
|
||||
if (want) {
|
||||
try {
|
||||
if (app.canvas) {
|
||||
app.canvas.style.display = 'block';
|
||||
app.canvas.style.visibility = 'visible';
|
||||
}
|
||||
app.ticker.start();
|
||||
} catch {
|
||||
/* ignore */
|
||||
@@ -240,6 +259,14 @@ export const PixiEffectsOverlay = forwardRef<PixiEffectsOverlayHandle, Props>(fu
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
if (app.canvas) {
|
||||
app.canvas.style.visibility = 'hidden';
|
||||
app.canvas.style.display = 'none';
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
|
||||
const applyMergedState = (committed: EffectsState | null, draft: EffectInstance | null): void => {
|
||||
@@ -265,16 +292,22 @@ export const PixiEffectsOverlay = forwardRef<PixiEffectsOverlayHandle, Props>(fu
|
||||
ref,
|
||||
() => ({
|
||||
setDraft: (instance) => {
|
||||
if (instance) setGpuBootRef.current(true);
|
||||
applyMergedState(committedStateRef.current, instance);
|
||||
},
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
/** Снижаем resolution на HiDPI — меньше пикселей в WebGL, визуально ок для оверлея эффектов. */
|
||||
const dpr = useMemo(() => Math.min(1.5, window.devicePixelRatio || 1), []);
|
||||
/** 1 CSS-пиксель: на Retina/M1 лишний dpr сильно бьёт по fill-rate поверх видео. */
|
||||
const dpr = useMemo(() => 1, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (effectsNeedWebGl(state, draftRef.current)) setGpuBoot(true);
|
||||
}, [state]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!gpuBoot) return;
|
||||
const host = hostRef.current;
|
||||
if (!host) return;
|
||||
|
||||
@@ -371,7 +404,7 @@ export const PixiEffectsOverlay = forwardRef<PixiEffectsOverlayHandle, Props>(fu
|
||||
host.replaceChildren();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
}, [gpuBoot]);
|
||||
|
||||
useEffect(() => {
|
||||
const host = hostRef.current;
|
||||
|
||||
Reference in New Issue
Block a user