feat(scene): video map editor parity and help updates

Enable scene editor, overlays, effects, and darkness on video scenes; brighten GM trap markers; document snap, NPC types, materials, and control controls in RU/EN help.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-08-07 08:23:08 +08:00
parent 8d5a68c71e
commit 1fbaaa6e77
17 changed files with 443 additions and 98 deletions
+158
View File
@@ -0,0 +1,158 @@
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { containMediaRect } from '../../shared/types/containMediaRect';
import { DEFAULT_SCENE_VIEW_CAMERA, type SceneViewCamera } from '../../shared/types/sceneView';
import styles from './ContainedVideo.module.css';
export type ContainedVideoProps = {
url: string;
/** Зум/пан как у RotatedImage в mode=contain. */
viewCamera?: SceneViewCamera | null;
onContentRectChange?: ((rect: { x: number; y: number; w: number; h: number }) => void) | undefined;
videoRef?: React.Ref<HTMLVideoElement | null>;
loop?: boolean;
muted?: boolean;
playsInline?: boolean;
preload?: React.VideoHTMLAttributes<HTMLVideoElement>['preload'];
className?: string;
style?: React.CSSProperties;
onTimeUpdate?: React.VideoHTMLAttributes<HTMLVideoElement>['onTimeUpdate'];
onLoadedMetadata?: React.VideoHTMLAttributes<HTMLVideoElement>['onLoadedMetadata'];
onError?: React.VideoHTMLAttributes<HTMLVideoElement>['onError'];
children?: React.ReactNode;
};
function useElementSize<T extends HTMLElement>() {
const ref = useRef<T | null>(null);
const [size, setSize] = useState<{ w: number; h: number }>({ w: 0, h: 0 });
useEffect(() => {
const el = ref.current;
if (!el) return;
const readLayoutSize = () => {
setSize({ w: el.clientWidth, h: el.clientHeight });
};
const ro = new ResizeObserver(() => {
readLayoutSize();
});
ro.observe(el);
readLayoutSize();
return () => ro.disconnect();
}, []);
return [ref, size] as const;
}
function assignRef<T>(ref: React.Ref<T> | undefined, value: T): void {
if (!ref) return;
if (typeof ref === 'function') {
ref(value);
return;
}
(ref as React.MutableRefObject<T>).current = value;
}
/**
* Video laid out like RotatedImage(mode=contain): reports the visible content rect
* so grid / traps / tokens / effects align with the letterboxed frame.
*/
export function ContainedVideo({
url,
viewCamera = null,
onContentRectChange,
videoRef,
loop = false,
muted = false,
playsInline = true,
preload = 'auto',
className,
style,
onTimeUpdate,
onLoadedMetadata,
onError,
children,
}: ContainedVideoProps) {
const [hostRef, size] = useElementSize<HTMLDivElement>();
const [mediaSize, setMediaSize] = useState<{ w: number; h: number } | null>(null);
const elRef = useRef<HTMLVideoElement | null>(null);
const cam = viewCamera ?? DEFAULT_SCENE_VIEW_CAMERA;
const viewScale = Math.max(1, cam.scale);
const viewOx = cam.ox;
const viewOy = cam.oy;
const syncMediaSize = (el: HTMLVideoElement) => {
const w0 = el.videoWidth || 0;
const h0 = el.videoHeight || 0;
if (w0 <= 0 || h0 <= 0) return;
setMediaSize((prev) => (prev && prev.w === w0 && prev.h === h0 ? prev : { w: w0, h: h0 }));
};
useLayoutEffect(() => {
const el = elRef.current;
if (!el) return;
if (el.readyState >= 1) syncMediaSize(el);
}, [url]);
const contentRect = useMemo(() => {
if (!mediaSize) return null;
return containMediaRect({
hostW: size.w,
hostH: size.h,
mediaW: mediaSize.w,
mediaH: mediaSize.h,
scale: viewScale,
ox: viewOx,
oy: viewOy,
});
}, [mediaSize, size.h, size.w, viewOx, viewOy, viewScale]);
useEffect(() => {
if (!onContentRectChange || !contentRect) return;
onContentRectChange(contentRect);
}, [contentRect, onContentRectChange]);
const w = contentRect?.w;
const h = contentRect?.h;
const leftPx = contentRect ? contentRect.x + contentRect.w / 2 : undefined;
const topPx = contentRect ? contentRect.y + contentRect.h / 2 : undefined;
return (
<div
ref={hostRef}
className={[styles.root, className].filter(Boolean).join(' ')}
style={style}
>
<video
ref={(el) => {
elRef.current = el;
assignRef(videoRef, el);
}}
className={styles.video}
src={url}
loop={loop}
muted={muted}
playsInline={playsInline}
preload={preload}
draggable={false}
onTimeUpdate={onTimeUpdate}
onLoadedMetadata={(e) => {
syncMediaSize(e.currentTarget);
onLoadedMetadata?.(e);
}}
onError={onError}
style={{
width: w ?? '100%',
height: h ?? '100%',
left: leftPx !== undefined ? `${String(leftPx)}px` : '50%',
top: topPx !== undefined ? `${String(topPx)}px` : '50%',
objectFit: mediaSize ? undefined : 'contain',
transform: 'translate(-50%, -50%)',
}}
>
{children}
</video>
</div>
);
}