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
@@ -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;
@@ -62,7 +62,7 @@ export function SceneGridOverlay({ grid, viewport }: Props) {
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas || !viewport || !grid?.enabled) return;
const dpr = Math.max(1, Math.min(2, window.devicePixelRatio || 1));
const dpr = 1;
const w = Math.max(1, Math.round(viewport.w));
const h = Math.max(1, Math.round(viewport.h));
canvas.width = Math.round(w * dpr);
@@ -13,10 +13,19 @@ export function useNpcsOverlayState(): readonly [
useEffect(() => {
void api.invoke(ipcChannels.npcsOverlay.getState, {}).then((r) => {
setState(r.state);
const next = r.state;
setState({
...next,
inspectNpcIds: next.inspectNpcIds ?? [],
inspectFocusNpcId: next.inspectFocusNpcId ?? null,
});
});
return api.on(ipcChannels.npcsOverlay.stateChanged, ({ state: next }) => {
setState(next);
setState({
...next,
inspectNpcIds: next.inspectNpcIds ?? [],
inspectFocusNpcId: next.inspectFocusNpcId ?? null,
});
});
}, [api]);
@@ -25,6 +25,15 @@
cursor: grabbing;
}
.circlePick {
cursor: pointer;
}
.circleDrop {
border-color: var(--accent, #a78bfa);
box-shadow: 0 0 0 3px var(--accent-fill-soft, rgba(167, 139, 250, 0.28));
}
.avatar {
position: absolute;
left: 50%;
@@ -47,6 +56,25 @@
);
}
.avatarEmptySolid {
background: #18181b;
}
.avatarEmptyHint {
position: absolute;
inset: 12px;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
font-size: 11px;
font-weight: 700;
line-height: 1.35;
color: var(--text2, #a1a1aa);
pointer-events: none;
user-select: none;
}
.namePlate {
max-width: 100%;
padding: 4px 10px;
@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useRef } from 'react';
import React, { useCallback, useEffect, useLayoutEffect, useRef, type ReactNode } from 'react';
import type { PlayerImageOffset } from '../../../shared/types/appPlayers';
import {
@@ -11,6 +11,17 @@ import {
import styles from './PlayerTokenView.module.css';
const SCALE_PERSIST_MS = 200;
function writeAvatarTransform(
img: HTMLImageElement | null,
offset: PlayerImageOffset,
scale: number,
) {
if (!img) return;
img.style.transform = `translate(-50%, -50%) translate(${String(offset.x * 100)}%, ${String(offset.y * 100)}%) scale(${String(scale)})`;
}
export type PlayerTokenViewProps = {
name: string;
imageUrl: string | null;
@@ -28,6 +39,16 @@ export type PlayerTokenViewProps = {
hideName?: boolean;
/** Session «Неактивен»: серый фильтр аватара. */
inactive?: boolean;
/** Текст/контент внутри пустого круга (только редактор, не маркеры на карте). */
emptyContent?: ReactNode;
/** Оверлей поверх круга (карандаш, drop-hint). */
circleOverlay?: ReactNode;
/** Подсветка круга при файловом DnD. */
dropActive?: boolean;
/** Клик по пустому кругу (выбор файла). */
onCircleClick?: () => void;
/** Сброс локального pan/zoom при смене сущности (npcId / playerId). */
syncKey?: string;
'data-testid'?: string;
};
@@ -44,13 +65,24 @@ export function PlayerTokenView({
className,
hideName = false,
inactive = false,
emptyContent,
circleOverlay,
dropActive = false,
onCircleClick,
syncKey,
'data-testid': testId,
}: PlayerTokenViewProps) {
const offset = clampPlayerImageOffset(imageOffset);
const scale = clampPlayerImageScale(imageScale ?? DEFAULT_PLAYER_IMAGE_SCALE);
const circleRef = useRef<HTMLDivElement | null>(null);
const scaleRef = useRef(scale);
scaleRef.current = scale;
const imgRef = useRef<HTMLImageElement | null>(null);
const visualOffsetRef = useRef(offset);
const visualScaleRef = useRef(scale);
const pendingOffsetRef = useRef<PlayerImageOffset | null>(null);
const pendingScaleRef = useRef<number | null>(null);
const scaleTimerRef = useRef(0);
const onImageOffsetChangeRef = useRef(onImageOffsetChange);
onImageOffsetChangeRef.current = onImageOffsetChange;
const onImageScaleChangeRef = useRef(onImageScaleChange);
onImageScaleChangeRef.current = onImageScaleChange;
const dragRef = useRef<{
@@ -61,9 +93,63 @@ export function PlayerTokenView({
origY: number;
} | null>(null);
useEffect(() => {
pendingOffsetRef.current = null;
pendingScaleRef.current = null;
dragRef.current = null;
visualOffsetRef.current = clampPlayerImageOffset(imageOffset);
visualScaleRef.current = clampPlayerImageScale(imageScale ?? DEFAULT_PLAYER_IMAGE_SCALE);
writeAvatarTransform(imgRef.current, visualOffsetRef.current, visualScaleRef.current);
// identity only — offset/scale sync is handled below
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [syncKey]);
useEffect(() => {
if (dragRef.current) return;
if (pendingOffsetRef.current) {
if (
Math.abs(offset.x - pendingOffsetRef.current.x) < 1e-6 &&
Math.abs(offset.y - pendingOffsetRef.current.y) < 1e-6
) {
pendingOffsetRef.current = null;
} else {
return;
}
}
visualOffsetRef.current = offset;
writeAvatarTransform(imgRef.current, offset, visualScaleRef.current);
}, [offset.x, offset.y]);
useEffect(() => {
if (scaleTimerRef.current) return;
if (pendingScaleRef.current != null) {
if (Math.abs(scale - pendingScaleRef.current) < 1e-6) {
pendingScaleRef.current = null;
} else {
return;
}
}
visualScaleRef.current = scale;
writeAvatarTransform(imgRef.current, visualOffsetRef.current, scale);
}, [scale]);
useLayoutEffect(() => {
writeAvatarTransform(imgRef.current, visualOffsetRef.current, visualScaleRef.current);
});
useEffect(() => {
return () => {
if (scaleTimerRef.current) {
window.clearTimeout(scaleTimerRef.current);
scaleTimerRef.current = 0;
onImageScaleChangeRef.current?.(visualScaleRef.current);
}
};
}, []);
const onPointerDown = useCallback(
(e: React.PointerEvent) => {
if (!panEnabled || !onImageOffsetChange) return;
if (!panEnabled || !onImageOffsetChangeRef.current || !imageUrl) return;
e.preventDefault();
e.stopPropagation();
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
@@ -71,45 +157,56 @@ export function PlayerTokenView({
pointerId: e.pointerId,
startX: e.clientX,
startY: e.clientY,
origX: offset.x,
origY: offset.y,
origX: visualOffsetRef.current.x,
origY: visualOffsetRef.current.y,
};
},
[offset.x, offset.y, onImageOffsetChange, panEnabled],
[imageUrl, panEnabled],
);
const onPointerMove = useCallback(
(e: React.PointerEvent) => {
const d = dragRef.current;
if (!d || d.pointerId !== e.pointerId || !onImageOffsetChange) return;
if (!d || d.pointerId !== e.pointerId) return;
const dx = (e.clientX - d.startX) / Math.max(1, sizePx);
const dy = (e.clientY - d.startY) / Math.max(1, sizePx);
onImageOffsetChange(clampPlayerImageOffset({ x: d.origX + dx, y: d.origY + dy }));
const next = clampPlayerImageOffset({ x: d.origX + dx, y: d.origY + dy });
visualOffsetRef.current = next;
writeAvatarTransform(imgRef.current, next, visualScaleRef.current);
},
[onImageOffsetChange, sizePx],
[sizePx],
);
const onPointerUp = useCallback((e: React.PointerEvent) => {
const d = dragRef.current;
if (!d || d.pointerId !== e.pointerId) return;
dragRef.current = null;
pendingOffsetRef.current = visualOffsetRef.current;
try {
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
} catch {
/* ignore */
}
onImageOffsetChangeRef.current?.(visualOffsetRef.current);
}, []);
useEffect(() => {
const el = circleRef.current;
if (!el || !panEnabled) return;
const onWheel = (e: WheelEvent) => {
const cb = onImageScaleChangeRef.current;
if (!cb) return;
if (!onImageScaleChangeRef.current) return;
e.preventDefault();
e.stopPropagation();
const direction = e.deltaY < 0 ? 1 : -1;
cb(clampPlayerImageScale(scaleRef.current + direction * PLAYER_IMAGE_SCALE_STEP));
const next = clampPlayerImageScale(visualScaleRef.current + direction * PLAYER_IMAGE_SCALE_STEP);
visualScaleRef.current = next;
writeAvatarTransform(imgRef.current, visualOffsetRef.current, next);
if (scaleTimerRef.current) window.clearTimeout(scaleTimerRef.current);
scaleTimerRef.current = window.setTimeout(() => {
scaleTimerRef.current = 0;
pendingScaleRef.current = visualScaleRef.current;
onImageScaleChangeRef.current?.(visualScaleRef.current);
}, SCALE_PERSIST_MS);
};
el.addEventListener('wheel', onWheel, { passive: false });
return () => el.removeEventListener('wheel', onWheel);
@@ -118,6 +215,7 @@ export function PlayerTokenView({
const rootClass = [styles.root, className ?? ''].filter(Boolean).join(' ');
const nameFontPx = Math.max(9, Math.min(13, Math.round(sizePx * 0.16)));
const nameGapPx = Math.max(2, Math.min(8, Math.round(sizePx * 0.06)));
const canPickEmpty = Boolean(onCircleClick) && !imageUrl;
return (
<div
@@ -127,31 +225,47 @@ export function PlayerTokenView({
>
<div
ref={circleRef}
className={[styles.circle, panEnabled ? styles.circlePan : ''].filter(Boolean).join(' ')}
data-token-circle="1"
className={[
styles.circle,
panEnabled && imageUrl ? styles.circlePan : '',
canPickEmpty ? styles.circlePick : '',
dropActive ? styles.circleDrop : '',
]
.filter(Boolean)
.join(' ')}
style={{
width: sizePx,
height: sizePx,
borderColor: inactive ? '#9ca3af' : ringColor,
borderColor: dropActive ? undefined : inactive ? '#9ca3af' : ringColor,
}}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
onClick={
canPickEmpty
? () => {
onCircleClick?.();
}
: undefined
}
>
{imageUrl ? (
<img
ref={imgRef}
className={styles.avatar}
src={imageUrl}
alt=""
draggable={false}
style={{
transform: `translate(-50%, -50%) translate(${String(offset.x * 100)}%, ${String(offset.y * 100)}%) scale(${String(scale)})`,
...(inactive ? { filter: 'grayscale(1)', opacity: 0.85 } : {}),
}}
style={inactive ? { filter: 'grayscale(1)', opacity: 0.85 } : undefined}
/>
) : (
<div className={styles.avatarEmpty} />
<div className={[styles.avatarEmpty, emptyContent ? styles.avatarEmptySolid : ''].filter(Boolean).join(' ')}>
{emptyContent}
</div>
)}
{circleOverlay}
</div>
{!hideName ? (
<div className={styles.namePlate} title={name} style={{ fontSize: nameFontPx }}>
@@ -129,4 +129,18 @@ void test('NpcsEditorApp: token appearance via PlayerTokenView', () => {
const src = fs.readFileSync(path.join(rendererRoot, 'npcs/NpcsEditorApp.tsx'), 'utf8');
assert.ok(src.includes('PlayerTokenView'));
assert.ok(src.includes('disposition') || src.includes('updateNpcFields'));
assert.ok(src.includes('avatarPickHint'));
assert.ok(src.includes('useFileDropZone'));
assert.ok(!src.includes("t('npcs.chooseAvatar')"));
assert.ok(src.includes('externalUrl'));
});
void test('NpcsApp: inspect cards vs Show checkbox, webview for external link', () => {
const src = fs.readFileSync(path.join(rendererRoot, 'npcs/NpcsApp.tsx'), 'utf8');
assert.ok(src.includes("kind: 'inspect.toggle'"));
assert.ok(src.includes('onActivate()'));
assert.ok(src.includes("kind: 'toggle'"));
assert.ok(src.includes('npcs.showOnScreen'));
assert.ok(src.includes('NpcExternalWebview') || src.includes('webview'));
assert.ok(src.includes('npcs.tabExternalLink'));
});