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:
Ivan Fontosh
2026-08-19 10:42:57 +08:00
parent 1a9f0f2557
commit 0f4bb76b41
35 changed files with 1189 additions and 259 deletions
@@ -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;