153be98780
Легенда и цифры на материале масштабируются от экрана игроков и не обрезаются жёлтой рамкой. Переключение сцен без записи project.json на диск. Co-authored-by: Cursor <cursoragent@cursor.com>
154 lines
5.4 KiB
TypeScript
154 lines
5.4 KiB
TypeScript
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||
|
||
import type { MaterialsZoomTool, NpcsZoomTool } from '../../../shared/types';
|
||
import styles from '../materials/MaterialOverlay.module.css';
|
||
|
||
import { overlayRootStyle, type SceneOverlayViewport } from './overlayViewport';
|
||
import { SceneOverlayViewContext } from './SceneOverlayViewContext';
|
||
|
||
export type SceneOverlayCloseAction = {
|
||
key: string;
|
||
label: string;
|
||
onClose: () => void;
|
||
};
|
||
|
||
type SceneOverlayHostProps = {
|
||
/** Есть ли что показывать (материал и/или NPC). */
|
||
active: boolean;
|
||
/**
|
||
* Область раскладки кадров материалов/NPC (и жёлтой рамки).
|
||
* На пульте — экран игроков (жёлтая рамка); на презентации не задаётся (весь экран).
|
||
*/
|
||
viewport?: SceneOverlayViewport | null;
|
||
/** Жёлтая рамка видимой области презентации (предпросмотр пульта). Без dim. */
|
||
showViewportGuide?: boolean;
|
||
zoomTool?: MaterialsZoomTool | NpcsZoomTool;
|
||
onZoomAt?: (nx: number, ny: number, target: EventTarget | null) => void;
|
||
closes?: readonly SceneOverlayCloseAction[];
|
||
/** Кадры материалов/NPC — внутри viewport (overflow hidden). */
|
||
children: React.ReactNode;
|
||
/** Блоки легенды — вне viewport, не обрезаются жёлтой рамкой. */
|
||
legendLayer?: React.ReactNode;
|
||
};
|
||
|
||
/**
|
||
* Общий слой подложки для Materials + NPCs.
|
||
* Dim — только при `active`, на весь родитель (экран презентации / рамка превью пульта).
|
||
* Кадры остаются в дочерних оверлеях (`embedded`) внутри `viewport`.
|
||
*/
|
||
export function SceneOverlayHost({
|
||
active,
|
||
viewport = null,
|
||
showViewportGuide = false,
|
||
zoomTool = null,
|
||
onZoomAt,
|
||
closes = [],
|
||
children,
|
||
legendLayer = null,
|
||
}: SceneOverlayHostProps) {
|
||
const outerRef = useRef<HTMLDivElement | null>(null);
|
||
const markerLayerRef = useRef<HTMLDivElement | null>(null);
|
||
const [view, setView] = useState({ w: 1, h: 1 });
|
||
|
||
const layoutViewport = useMemo((): SceneOverlayViewport => {
|
||
if (viewport && viewport.w > 0 && viewport.h > 0) return viewport;
|
||
return { x: 0, y: 0, w: view.w, h: view.h };
|
||
}, [viewport, view.h, view.w]);
|
||
|
||
useEffect(() => {
|
||
const el = outerRef.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, showViewportGuide, viewport]);
|
||
|
||
const ctx = useMemo(
|
||
() => ({
|
||
rootRef: outerRef,
|
||
markerLayerRef,
|
||
view: { w: layoutViewport.w, h: layoutViewport.h },
|
||
viewport: layoutViewport,
|
||
}),
|
||
[layoutViewport],
|
||
);
|
||
|
||
if (!active && !showViewportGuide) 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 = outerRef.current;
|
||
if (!root || layoutViewport.w <= 0 || layoutViewport.h <= 0) return { nx: 0.5, ny: 0.5 };
|
||
return {
|
||
nx: (clientX - root.getBoundingClientRect().left - layoutViewport.x) / layoutViewport.w,
|
||
ny: (clientY - root.getBoundingClientRect().top - layoutViewport.y) / layoutViewport.h,
|
||
};
|
||
};
|
||
|
||
return (
|
||
<SceneOverlayViewContext.Provider value={ctx}>
|
||
{active ? <div className={styles.dim} aria-hidden /> : null}
|
||
<div
|
||
ref={outerRef}
|
||
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 ref={markerLayerRef} className={styles.markerLayer} />
|
||
<div
|
||
className={styles.hostViewport}
|
||
style={overlayRootStyle(layoutViewport)}
|
||
role="presentation"
|
||
>
|
||
{showViewportGuide ? <div className={styles.viewportGuide} aria-hidden /> : null}
|
||
{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>
|
||
{legendLayer ? <div className={styles.legendLayer}>{legendLayer}</div> : null}
|
||
</div>
|
||
</SceneOverlayViewContext.Provider>
|
||
);
|
||
}
|