Files
DndGamePlayer/app/renderer/shared/npcs/NpcsSceneOverlay.tsx
T
Ivan Fontosh d9fbecf5a7 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>
2026-07-23 11:58:16 +08:00

505 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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';
export type NpcsSceneOverlayItem = {
npcId: NpcId;
assetId: AssetId | null;
layout: NpcsOverlayLayout;
};
type NpcsSceneOverlayProps = {
items: readonly NpcsSceneOverlayItem[];
editable?: boolean;
zoomTool?: NpcsZoomTool;
showClose?: boolean;
onClose?: () => void;
closeLabel?: string;
rotateLabel?: string;
onLayoutChange?: (npcId: NpcId, layout: NpcsOverlayLayout) => void;
onZoomAt?: (npcId: NpcId | undefined, nx: number, ny: number) => void;
/** Без собственного root/dim — внутри `SceneOverlayHost`. */
embedded?: boolean;
};
function RotateIcon() {
return (
<svg className={styles.frameRotateSvg} viewBox="0 0 24 24" width="16" height="16" aria-hidden>
<path
d="M20 12a8 8 0 1 1-2.2-5.4"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
/>
<path d="M20 4v5h-5" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
</svg>
);
}
function nextBaseSize(
viewW: number,
viewH: number,
naturalW: number,
naturalH: number,
): { w: number; h: number } {
if (naturalW <= 0 || naturalH <= 0 || viewW <= 0 || viewH <= 0) return { w: 200, h: 120 };
const maxW = viewW * 0.92;
const maxH = viewH * 0.88;
const fit = Math.min(maxW / naturalW, maxH / naturalH);
return { w: naturalW * fit, h: naturalH * fit };
}
function pointerAngleDeg(centerX: number, centerY: number, clientX: number, clientY: number): number {
return (Math.atan2(clientY - centerY, clientX - centerX) * 180) / Math.PI;
}
function shortestAngleDelta(fromDeg: number, toDeg: number): number {
let d = toDeg - fromDeg;
while (d > 180) d -= 360;
while (d < -180) d += 360;
return d;
}
function screenToLocal(
clientX: number,
clientY: number,
centerX: number,
centerY: number,
rotationDeg: number,
): { x: number; y: number } {
const rad = (-rotationDeg * Math.PI) / 180;
const dx = clientX - centerX;
const dy = clientY - centerY;
return {
x: dx * Math.cos(rad) - dy * Math.sin(rad),
y: dx * Math.sin(rad) + dy * Math.cos(rad),
};
}
function localToScreenOffset(localX: number, localY: number, rotationDeg: number): { x: number; y: number } {
const rad = (rotationDeg * Math.PI) / 180;
return {
x: localX * Math.cos(rad) - localY * Math.sin(rad),
y: localX * Math.sin(rad) + localY * Math.cos(rad),
};
}
function NpcAvatarFrame({
item,
view,
rootRef,
editable,
zoomTool,
rotateLabel,
onLayoutChange,
}: {
item: NpcsSceneOverlayItem;
view: { w: number; h: number };
rootRef: React.RefObject<HTMLDivElement | null>;
editable: boolean;
zoomTool: NpcsZoomTool;
rotateLabel: string;
onLayoutChange?: (npcId: NpcId, layout: NpcsOverlayLayout) => void;
}) {
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 }
| {
mode: 'resize';
corner: Corner;
origin: NpcsOverlayLayout;
baseW: number;
baseH: number;
viewW: number;
viewH: number;
centerClientX: number;
centerClientY: number;
}
| {
mode: 'rotate';
origin: NpcsOverlayLayout;
startPointerAngle: number;
centerX: number;
centerY: number;
}
| null
>(null);
useEffect(() => {
if (dragRef.current) return;
setDraftLayout(null);
pendingLayoutRef.current = null;
}, [item.layout]);
if (!item.assetId || !url) return null;
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;
const h = base.h * layout.scale;
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;
if (drag.mode === 'rotate') {
const angle = pointerAngleDeg(drag.centerX, drag.centerY, e.clientX, e.clientY);
const delta = shortestAngleDelta(drag.startPointerAngle, angle);
publishDraft({
...drag.origin,
rotationDeg: drag.origin.rotationDeg + delta,
});
return;
}
const root = rootRef.current;
if (!root) return;
const r = root.getBoundingClientRect();
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);
publishDraft({
...drag.origin,
cx: drag.origin.cx + dx,
cy: drag.origin.cy + dy,
});
return;
}
const { baseW, baseH, viewW, viewH, origin, corner, centerClientX, centerClientY } = drag;
const originW = baseW * origin.scale;
const originH = baseH * origin.scale;
const originLeft = -originW / 2;
const originTop = -originH / 2;
const originRight = originW / 2;
const originBottom = originH / 2;
let anchorX = originLeft;
let anchorY = originTop;
if (corner === 'nw') {
anchorX = originRight;
anchorY = originBottom;
} else if (corner === 'ne') {
anchorX = originLeft;
anchorY = originBottom;
} else if (corner === 'sw') {
anchorX = originRight;
anchorY = originTop;
}
const local = screenToLocal(
e.clientX,
e.clientY,
centerClientX,
centerClientY,
origin.rotationDeg ?? 0,
);
const newW = Math.max(8, Math.abs(local.x - anchorX));
const newH = Math.max(8, Math.abs(local.y - anchorY));
const nextScale = Math.max(newW / Math.max(1, baseW), newH / Math.max(1, baseH));
const ww = baseW * nextScale;
const hh = baseH * nextScale;
let nextLeft = anchorX;
let nextTop = anchorY;
if (corner === 'nw') {
nextLeft = anchorX - ww;
nextTop = anchorY - hh;
} else if (corner === 'ne') {
nextLeft = anchorX;
nextTop = anchorY - hh;
} else if (corner === 'sw') {
nextLeft = anchorX - ww;
nextTop = anchorY;
}
const localCenterX = nextLeft + ww / 2;
const localCenterY = nextTop + hh / 2;
const screenOffset = localToScreenOffset(localCenterX, localCenterY, origin.rotationDeg ?? 0);
publishDraft({
...origin,
cx: origin.cx + screenOffset.x / Math.max(1, viewW),
cy: origin.cy + screenOffset.y / Math.max(1, viewH),
scale: nextScale,
});
};
const endDrag = () => {
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 = () => {
const root = rootRef.current;
if (!root) return { x: 0, y: 0 };
const r = root.getBoundingClientRect();
return {
x: r.left + layout.cx * r.width,
y: r.top + layout.cy * r.height,
};
};
const startDrag = (e: React.PointerEvent, mode: 'move' | 'resize', corner?: Corner) => {
if (!editable || !onLayoutChange || zoomTool) return;
e.preventDefault();
e.stopPropagation();
if (mode === 'move') {
dragRef.current = { mode: 'move', startX: e.clientX, startY: e.clientY, origin: { ...layout } };
} else {
const center = layoutCenterClient();
dragRef.current = {
mode: 'resize',
corner: corner ?? 'se',
origin: { ...layout },
baseW: base.w,
baseH: base.h,
viewW: view.w,
viewH: view.h,
centerClientX: center.x,
centerClientY: center.y,
};
}
window.addEventListener('pointermove', onPointerMove);
window.addEventListener('pointerup', endDrag);
};
const startRotate = (e: React.PointerEvent) => {
if (!editable || !onLayoutChange || zoomTool) return;
e.preventDefault();
e.stopPropagation();
const center = layoutCenterClient();
dragRef.current = {
mode: 'rotate',
origin: { ...layout },
startPointerAngle: pointerAngleDeg(center.x, center.y, e.clientX, e.clientY),
centerX: center.x,
centerY: center.y,
};
window.addEventListener('pointermove', onPointerMove);
window.addEventListener('pointerup', endDrag);
};
return (
<div
className={[styles.frame, editable && !zoomTool ? styles.frameEditable : ''].filter(Boolean).join(' ')}
data-npc-id={item.npcId}
data-overlay-kind="npc"
style={{
left,
top,
width: w,
height: h,
transform: `rotate(${String(rotationDeg)}deg)`,
transformOrigin: 'center center',
}}
onPointerDown={(e) => {
if (zoomTool) return;
startDrag(e, 'move');
}}
>
<img
className={styles.image}
src={url}
alt=""
draggable={false}
style={{
width: w,
height: h,
transform: 'translate(-50%, -50%)',
}}
onLoad={(e) => {
const img = e.currentTarget;
setNatural({ w: img.naturalWidth || 1, h: img.naturalHeight || 1 });
}}
/>
{editable && !zoomTool
? (['nw', 'ne', 'sw', 'se'] as const).map((corner) => (
<button
key={corner}
type="button"
className={[styles.handle, styles[`handle_${corner}`]].join(' ')}
aria-label={corner}
onPointerDown={(e) => startDrag(e, 'resize', corner)}
/>
))
: null}
{editable && !zoomTool && onLayoutChange ? (
<button
type="button"
className={styles.frameRotate}
aria-label={rotateLabel}
title={rotateLabel}
onPointerDown={startRotate}
>
<RotateIcon />
</button>
) : null}
</div>
);
}
export function NpcsSceneOverlay({
items,
editable = false,
zoomTool = null,
showClose = false,
onClose,
closeLabel = 'Close',
rotateLabel = 'Rotate',
onLayoutChange,
onZoomAt,
embedded = false,
}: NpcsSceneOverlayProps) {
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(() => {
if (embedded) return;
const el = localRootRef.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);
setLocalView((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);
};
}, [embedded, items.length]);
if (items.length === 0) return null;
const interactive = editable || showClose || Boolean(zoomTool);
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),
};
};
const npcIdFromTarget = (target: EventTarget | null): NpcId | undefined => {
if (!(target instanceof Element)) return undefined;
const frame = target.closest('[data-npc-id]');
const raw = frame?.getAttribute('data-npc-id');
return raw ? (raw as NpcId) : undefined;
};
const frames = items.map((item) =>
onLayoutChange ? (
<NpcAvatarFrame
key={item.npcId}
item={item}
view={view}
rootRef={rootRef}
editable={editable}
zoomTool={zoomTool}
rotateLabel={rotateLabel}
onLayoutChange={onLayoutChange}
/>
) : (
<NpcAvatarFrame
key={item.npcId}
item={item}
view={view}
rootRef={rootRef}
editable={editable}
zoomTool={zoomTool}
rotateLabel={rotateLabel}
/>
),
);
if (embedded) {
return <>{frames}</>;
}
return (
<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"
className={styles.close}
onClick={onClose}
aria-label={closeLabel}
title={closeLabel}
>
×
</button>
) : null}
</div>
);
}