feat(scene): shared zoom/pan for control and presentation
Keep effects pinned to map coordinates when the viewport changes; materials/NPC overlays stay screen-fixed. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -8,7 +8,12 @@ import {
|
||||
isNodeInSideStoryline,
|
||||
listSideStoryStarts,
|
||||
} from '../../shared/graph/sceneGraphLineage';
|
||||
import type { GraphNodeId, Scene, SceneId } from '../../shared/types';
|
||||
import type { GraphNodeId, Scene, SceneId, SceneViewCamera } from '../../shared/types';
|
||||
import {
|
||||
DEFAULT_SCENE_VIEW_CAMERA,
|
||||
sceneViewPanBy,
|
||||
sceneViewZoomAt,
|
||||
} from '../../shared/types/sceneView';
|
||||
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
|
||||
import { isSceneDescriptionEmpty } from '../editor/sceneDescriptionHtml';
|
||||
import { getDndApi } from '../shared/dndApi';
|
||||
@@ -27,6 +32,7 @@ import { useMaterialsOverlayState } from '../shared/materials/useMaterialsOverla
|
||||
import { NpcsSceneOverlay } from '../shared/npcs/NpcsSceneOverlay';
|
||||
import { useNpcsOverlayState } from '../shared/npcs/useNpcsOverlayState';
|
||||
import { SceneOverlayHost } from '../shared/sceneOverlay/SceneOverlayHost';
|
||||
import { useSceneViewState } from '../shared/sceneView/useSceneViewState';
|
||||
import { Button } from '../shared/ui/controls';
|
||||
import { Surface } from '../shared/ui/Surface';
|
||||
import { useAssetUrl } from '../shared/useAssetImageUrl';
|
||||
@@ -113,6 +119,8 @@ export function ControlApp() {
|
||||
const [fxState, fx] = useEffectsState();
|
||||
const [effectsSfxGainUi, setEffectsSfxGainUi] = useState(() => getEffectsSfxGain());
|
||||
const [sdState, sd] = useSceneDarknessState();
|
||||
const [sceneView, sceneViewApi] = useSceneViewState();
|
||||
const [sceneViewDraft, setSceneViewDraft] = useState<SceneViewCamera | null>(null);
|
||||
const [materialsOverlay, materialsApi] = useMaterialsOverlayState();
|
||||
const [npcsOverlay, npcsApi] = useNpcsOverlayState();
|
||||
const [session, setSession] = useState<SessionState | null>(null);
|
||||
@@ -139,7 +147,13 @@ export function ControlApp() {
|
||||
const allowCampaignAudioRef = useRef<boolean>(true);
|
||||
const audioUnmountRef = useRef(false);
|
||||
const previewHostRef = useRef<HTMLDivElement | null>(null);
|
||||
const previewFrameRef = useRef<HTMLDivElement | null>(null);
|
||||
const previewVideoRef = useRef<HTMLVideoElement | null>(null);
|
||||
const sceneViewRef = useRef<SceneViewCamera>(DEFAULT_SCENE_VIEW_CAMERA);
|
||||
const scenePanRef = useRef<{ lastX: number; lastY: number; pointerId: number } | null>(null);
|
||||
const spaceDownRef = useRef(false);
|
||||
const sceneViewPublishRafRef = useRef(0);
|
||||
const pendingSceneViewRef = useRef<SceneViewCamera | null>(null);
|
||||
const brushRef = useRef<{
|
||||
tool:
|
||||
| 'fog'
|
||||
@@ -649,6 +663,84 @@ export function ControlApp() {
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
const activeSceneViewCamera: SceneViewCamera = sceneViewDraft ??
|
||||
(sceneView
|
||||
? { scale: sceneView.scale, ox: sceneView.ox, oy: sceneView.oy }
|
||||
: DEFAULT_SCENE_VIEW_CAMERA);
|
||||
sceneViewRef.current = activeSceneViewCamera;
|
||||
|
||||
useEffect(() => {
|
||||
if (scenePanRef.current) return;
|
||||
setSceneViewDraft(null);
|
||||
}, [sceneView?.revision]);
|
||||
|
||||
function publishSceneViewCamera(camera: SceneViewCamera): void {
|
||||
setSceneViewDraft(camera);
|
||||
sceneViewRef.current = camera;
|
||||
pendingSceneViewRef.current = camera;
|
||||
if (sceneViewPublishRafRef.current !== 0) return;
|
||||
sceneViewPublishRafRef.current = requestAnimationFrame(() => {
|
||||
sceneViewPublishRafRef.current = 0;
|
||||
const next = pendingSceneViewRef.current;
|
||||
if (!next) return;
|
||||
void sceneViewApi.dispatch({ kind: 'set', camera: next });
|
||||
});
|
||||
}
|
||||
const publishSceneViewCameraRef = useRef(publishSceneViewCamera);
|
||||
publishSceneViewCameraRef.current = publishSceneViewCamera;
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.code === 'Space') spaceDownRef.current = true;
|
||||
};
|
||||
const onKeyUp = (e: KeyboardEvent) => {
|
||||
if (e.code === 'Space') spaceDownRef.current = false;
|
||||
};
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
window.addEventListener('keyup', onKeyUp);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKeyDown);
|
||||
window.removeEventListener('keyup', onKeyUp);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const frame = previewFrameRef.current;
|
||||
if (!frame || isVideoPreviewScene) return;
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
e.preventDefault();
|
||||
const host = previewHostRef.current;
|
||||
const cr = previewContentRectRef.current;
|
||||
if (!host || !cr) return;
|
||||
const r = host.getBoundingClientRect();
|
||||
const cam = sceneViewRef.current;
|
||||
const containW = cr.w / Math.max(1e-6, cam.scale);
|
||||
const containH = cr.h / Math.max(1e-6, cam.scale);
|
||||
const factor = e.deltaY < 0 ? 1.12 : 1 / 1.12;
|
||||
const next = sceneViewZoomAt(cam, {
|
||||
hostW: r.width,
|
||||
hostH: r.height,
|
||||
containW,
|
||||
containH,
|
||||
hostX: e.clientX - r.left,
|
||||
hostY: e.clientY - r.top,
|
||||
factor,
|
||||
});
|
||||
publishSceneViewCameraRef.current(next);
|
||||
};
|
||||
frame.addEventListener('wheel', onWheel, { passive: false });
|
||||
return () => frame.removeEventListener('wheel', onWheel);
|
||||
}, [isVideoPreviewScene]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (sceneViewPublishRafRef.current !== 0) {
|
||||
cancelAnimationFrame(sceneViewPublishRafRef.current);
|
||||
sceneViewPublishRafRef.current = 0;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
function audioStatus(group: 'scene' | 'campaign', assetId: string): { label: string; detail?: string } {
|
||||
const el =
|
||||
group === 'scene'
|
||||
@@ -1541,11 +1633,20 @@ export function ControlApp() {
|
||||
<div className={styles.spacer10} />
|
||||
{isVideoPreviewScene ? <div className={styles.videoHint}>{t('control.videoBrushHint')}</div> : null}
|
||||
<div className={styles.spacer10} />
|
||||
<div className={styles.previewFrame}>
|
||||
<div
|
||||
ref={previewFrameRef}
|
||||
className={styles.previewFrame}
|
||||
title={
|
||||
isVideoPreviewScene
|
||||
? undefined
|
||||
: 'Колесо — зум; перетаскивание СКМ/ПКМ или Space+ЛКМ — пан'
|
||||
}
|
||||
>
|
||||
<div ref={previewHostRef} className={styles.previewHost}>
|
||||
<ControlScenePreview
|
||||
session={session}
|
||||
videoRef={previewVideoRef}
|
||||
viewCamera={activeSceneViewCamera}
|
||||
onContentRectChange={setPreviewContentRect}
|
||||
/>
|
||||
</div>
|
||||
@@ -1577,6 +1678,9 @@ export function ControlApp() {
|
||||
/>
|
||||
<div
|
||||
className={styles.brushLayer}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
onPointerEnter={(e) => {
|
||||
const p = toNPoint(e);
|
||||
if (!p) return;
|
||||
@@ -1588,6 +1692,19 @@ export function ControlApp() {
|
||||
layoutBrushCursor();
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
const panWithPrimary = e.button === 0 && (spaceDownRef.current || e.altKey);
|
||||
const isPan = e.button === 1 || e.button === 2 || panWithPrimary;
|
||||
if (isPan) {
|
||||
e.preventDefault();
|
||||
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
|
||||
scenePanRef.current = {
|
||||
lastX: e.clientX,
|
||||
lastY: e.clientY,
|
||||
pointerId: e.pointerId,
|
||||
};
|
||||
return;
|
||||
}
|
||||
if (e.button !== 0) return;
|
||||
const p = toNPoint(e);
|
||||
if (!p) return;
|
||||
cursorPosRef.current = p;
|
||||
@@ -1622,6 +1739,22 @@ export function ControlApp() {
|
||||
pushDraftToPixi();
|
||||
}}
|
||||
onPointerMove={(e) => {
|
||||
const pan = scenePanRef.current;
|
||||
if (pan && pan.pointerId === e.pointerId) {
|
||||
const cr = previewContentRectRef.current;
|
||||
if (!cr) return;
|
||||
const cam = sceneViewRef.current;
|
||||
const containW = cr.w / Math.max(1e-6, cam.scale);
|
||||
const containH = cr.h / Math.max(1e-6, cam.scale);
|
||||
const dx = e.clientX - pan.lastX;
|
||||
const dy = e.clientY - pan.lastY;
|
||||
pan.lastX = e.clientX;
|
||||
pan.lastY = e.clientY;
|
||||
publishSceneViewCamera(
|
||||
sceneViewPanBy(cam, { containW, containH, dx, dy }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const p = toNPoint(e);
|
||||
if (!p) return;
|
||||
cursorPosRef.current = p;
|
||||
@@ -1662,10 +1795,22 @@ export function ControlApp() {
|
||||
}
|
||||
scheduleDraftRepaint();
|
||||
}}
|
||||
onPointerUp={() => {
|
||||
onPointerUp={(e) => {
|
||||
if (scenePanRef.current?.pointerId === e.pointerId) {
|
||||
scenePanRef.current = null;
|
||||
const pending = pendingSceneViewRef.current;
|
||||
if (pending) {
|
||||
void sceneViewApi.dispatch({ kind: 'set', camera: pending });
|
||||
}
|
||||
return;
|
||||
}
|
||||
void commitStroke();
|
||||
}}
|
||||
onPointerCancel={() => {
|
||||
onPointerCancel={(e) => {
|
||||
if (scenePanRef.current?.pointerId === e.pointerId) {
|
||||
scenePanRef.current = null;
|
||||
return;
|
||||
}
|
||||
if (brushRef.current?.tool === 'exploreBrush') {
|
||||
void sd.dispatch({ kind: 'draft.set', draft: null });
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { computeTimeSec } from '../../main/video/videoPlaybackStore';
|
||||
import type { SessionState } from '../../shared/ipc/contracts';
|
||||
import type { SceneViewCamera } from '../../shared/types';
|
||||
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
|
||||
import { RotatedImage } from '../shared/RotatedImage';
|
||||
import { useAssetUrl } from '../shared/useAssetImageUrl';
|
||||
@@ -12,6 +13,7 @@ import styles from './ControlScenePreview.module.css';
|
||||
type Props = {
|
||||
session: SessionState | null;
|
||||
videoRef: React.RefObject<HTMLVideoElement | null>;
|
||||
viewCamera?: SceneViewCamera | null;
|
||||
onContentRectChange?: (rect: { x: number; y: number; w: number; h: number }) => void;
|
||||
};
|
||||
|
||||
@@ -23,7 +25,7 @@ function fmt(sec: number): string {
|
||||
return `${String(m)}:${String(r).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function ControlScenePreview({ session, videoRef, onContentRectChange }: Props) {
|
||||
export function ControlScenePreview({ session, videoRef, viewCamera = null, onContentRectChange }: Props) {
|
||||
const { t } = useEditorI18n();
|
||||
const [vp, video] = useVideoPlaybackState();
|
||||
const scene =
|
||||
@@ -91,7 +93,13 @@ export function ControlScenePreview({ session, videoRef, onContentRectChange }:
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
{url && scene?.previewAssetType === 'image' ? (
|
||||
<RotatedImage url={url} rotationDeg={rot} mode="contain" onContentRectChange={onContentRectChange} />
|
||||
<RotatedImage
|
||||
url={url}
|
||||
rotationDeg={rot}
|
||||
mode="contain"
|
||||
viewCamera={viewCamera}
|
||||
onContentRectChange={onContentRectChange}
|
||||
/>
|
||||
) : url && isVideo ? (
|
||||
<video
|
||||
ref={(el) => {
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useMaterialsOverlayState } from './materials/useMaterialsOverlayState';
|
||||
import { NpcsSceneOverlay } from './npcs/NpcsSceneOverlay';
|
||||
import { useNpcsOverlayState } from './npcs/useNpcsOverlayState';
|
||||
import { SceneOverlayHost } from './sceneOverlay/SceneOverlayHost';
|
||||
import { useSceneViewState } from './sceneView/useSceneViewState';
|
||||
import styles from './PresentationView.module.css';
|
||||
import { RotatedImage } from './RotatedImage';
|
||||
import { useAssetUrl } from './useAssetImageUrl';
|
||||
@@ -34,6 +35,7 @@ export function PresentationView({
|
||||
}: PresentationViewProps) {
|
||||
const [fxState] = useEffectsState();
|
||||
const [sdState] = useSceneDarknessState();
|
||||
const [sceneView] = useSceneViewState();
|
||||
const [materialsOverlay] = useMaterialsOverlayState();
|
||||
const [npcsOverlay] = useNpcsOverlayState();
|
||||
const [vp] = useVideoPlaybackState();
|
||||
@@ -132,6 +134,7 @@ export function PresentationView({
|
||||
url={shownImageUrl}
|
||||
rotationDeg={rot}
|
||||
mode="contain"
|
||||
viewCamera={sceneView}
|
||||
onContentRectChange={setContentRect}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
DEFAULT_SCENE_VIEW_CAMERA,
|
||||
type SceneViewCamera,
|
||||
} from '../../shared/types/sceneView';
|
||||
|
||||
import styles from './RotatedImage.module.css';
|
||||
|
||||
type Mode = 'cover' | 'contain';
|
||||
@@ -13,6 +18,8 @@ type RotatedImageProps = {
|
||||
decoding?: React.ImgHTMLAttributes<HTMLImageElement>['decoding'];
|
||||
/** Высота/ширина полностью контролируются родителем. */
|
||||
style?: React.CSSProperties;
|
||||
/** Зум/пан поверх contain/cover (только для mode=contain в сценах). */
|
||||
viewCamera?: SceneViewCamera | null;
|
||||
/** Прямоугольник видимого контента (contain/cover) внутри контейнера. */
|
||||
onContentRectChange?: ((rect: { x: number; y: number; w: number; h: number }) => void) | undefined;
|
||||
};
|
||||
@@ -47,12 +54,18 @@ export function RotatedImage({
|
||||
loading,
|
||||
decoding,
|
||||
style,
|
||||
viewCamera = null,
|
||||
onContentRectChange,
|
||||
}: RotatedImageProps) {
|
||||
const [ref, size] = useElementSize<HTMLDivElement>();
|
||||
const [imgSize, setImgSize] = useState<{ w: number; h: number } | null>(null);
|
||||
const imgRef = useRef<HTMLImageElement | null>(null);
|
||||
|
||||
const cam = viewCamera ?? DEFAULT_SCENE_VIEW_CAMERA;
|
||||
const viewScale = mode === 'contain' ? Math.max(1, cam.scale) : 1;
|
||||
const viewOx = mode === 'contain' ? cam.ox : 0.5;
|
||||
const viewOy = mode === 'contain' ? cam.oy : 0.5;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
// If the image is served from cache, onLoad may fire before listeners attach.
|
||||
// Reading from the <img> element itself is the most reliable source.
|
||||
@@ -66,7 +79,7 @@ export function RotatedImage({
|
||||
setImgSize((prev) => (prev && prev.w === w0 && prev.h === h0 ? prev : { w: w0, h: h0 }));
|
||||
}, [url]);
|
||||
|
||||
const scale = useMemo(() => {
|
||||
const fitScale = useMemo(() => {
|
||||
if (!imgSize) return 1;
|
||||
if (size.w <= 1 || size.h <= 1) return 1;
|
||||
const rotated = rotationDeg === 90 || rotationDeg === 270;
|
||||
@@ -77,21 +90,28 @@ export function RotatedImage({
|
||||
return mode === 'cover' ? Math.max(sx, sy) : Math.min(sx, sy);
|
||||
}, [imgSize, mode, rotationDeg, size.h, size.w]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!onContentRectChange) return;
|
||||
if (!imgSize) return;
|
||||
if (size.w <= 1 || size.h <= 1) return;
|
||||
const scale = fitScale * viewScale;
|
||||
|
||||
const contentRect = useMemo(() => {
|
||||
if (!imgSize) return null;
|
||||
if (size.w <= 1 || size.h <= 1) return null;
|
||||
const rotated = rotationDeg === 90 || rotationDeg === 270;
|
||||
// Bounding-box размеров после rotate(): при 90/270 меняются местами.
|
||||
const bw = (rotated ? imgSize.h : imgSize.w) * scale;
|
||||
const bh = (rotated ? imgSize.w : imgSize.h) * scale;
|
||||
const x = (size.w - bw) / 2;
|
||||
const y = (size.h - bh) / 2;
|
||||
onContentRectChange({ x, y, w: bw, h: bh });
|
||||
}, [imgSize, mode, onContentRectChange, rotationDeg, scale, size.h, size.w]);
|
||||
const x = size.w / 2 - viewOx * bw;
|
||||
const y = size.h / 2 - viewOy * bh;
|
||||
return { x, y, w: bw, h: bh };
|
||||
}, [imgSize, rotationDeg, scale, size.h, size.w, viewOx, viewOy]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!onContentRectChange || !contentRect) return;
|
||||
onContentRectChange(contentRect);
|
||||
}, [contentRect, onContentRectChange]);
|
||||
|
||||
const w = imgSize ? imgSize.w * scale : undefined;
|
||||
const h = imgSize ? imgSize.h * scale : undefined;
|
||||
const leftPx = contentRect ? contentRect.x + contentRect.w / 2 : undefined;
|
||||
const topPx = contentRect ? contentRect.y + contentRect.h / 2 : undefined;
|
||||
|
||||
return (
|
||||
<div ref={ref} className={styles.root} style={style}>
|
||||
@@ -117,6 +137,8 @@ export function RotatedImage({
|
||||
style={{
|
||||
width: w ?? '100%',
|
||||
height: h ?? '100%',
|
||||
left: leftPx !== undefined ? `${String(leftPx)}px` : '50%',
|
||||
top: topPx !== undefined ? `${String(topPx)}px` : '50%',
|
||||
objectFit: imgSize ? undefined : mode,
|
||||
transform: `translate(-50%, -50%) rotate(${String(rotationDeg)}deg)`,
|
||||
}}
|
||||
|
||||
@@ -38,3 +38,11 @@ void test('PxiEffectsOverlay: lazy VFX packs + idle ticker stop', () => {
|
||||
);
|
||||
assert.ok(src.includes('Lazy VFX'));
|
||||
});
|
||||
|
||||
void test('PxiEffectsOverlay: эффекты перекладываются при смене viewport (зум/пан)', () => {
|
||||
const src = fs.readFileSync(path.join(here, 'PxiEffectsOverlay.tsx'), 'utf8');
|
||||
assert.ok(src.includes('relayoutInstanceNode'));
|
||||
assert.ok(src.includes('instanceContentSig'));
|
||||
assert.ok(src.includes('viewportSig'));
|
||||
assert.match(src, /\$\{contentSig\}@\$\{viewportSig\(viewport\)\}/);
|
||||
});
|
||||
|
||||
@@ -388,8 +388,15 @@ export const PixiEffectsOverlay = forwardRef<PixiEffectsOverlayHandle, Props>(fu
|
||||
else viewportRef.current = { x: 0, y: 0, w: sizeRef.current.w, h: sizeRef.current.h };
|
||||
const pixi = pixiRef.current;
|
||||
const root = rootRef.current;
|
||||
const app = appRef.current;
|
||||
if (!pixi || !root) return;
|
||||
syncNodes(pixi, root, nodesRef.current, stateRef.current, sizeRef.current, viewportRef.current);
|
||||
// При остановленном ticker (или между кадрами) иначе зум/пан не отрисуется.
|
||||
try {
|
||||
app?.render?.();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, [viewport]);
|
||||
|
||||
const hostClass = [styles.host, interactive ? styles.hostInteractive : styles.hostPassthrough].join(' ');
|
||||
@@ -421,7 +428,8 @@ function syncNodes(
|
||||
}
|
||||
if (!state) return;
|
||||
for (const inst of liveInstances) {
|
||||
const sig = instanceSig(inst, viewport);
|
||||
const contentSig = instanceContentSig(inst);
|
||||
const sig = `${contentSig}@${viewportSig(viewport)}`;
|
||||
const existing = nodes.get(inst.id);
|
||||
if (existing && (existing as any).__sig === sig) continue;
|
||||
// Water draft: перерисовываем Graphics in-place (без destroy/create на каждую точку).
|
||||
@@ -434,6 +442,16 @@ function syncNodes(
|
||||
const halfW = Math.max(1.5, inst.radiusN * Math.min(viewport.w, viewport.h));
|
||||
redrawWaterDraft((existing as any).__fx.g, inst, viewport, halfW);
|
||||
existing.alpha = Math.max(0.35, Math.min(0.95, inst.opacity * 1.1));
|
||||
(existing as any).__contentSig = contentSig;
|
||||
(existing as any).__sig = sig;
|
||||
continue;
|
||||
}
|
||||
// Тот же инстанс, сменился только viewport (зум/пан) — двигаем in-place.
|
||||
if (
|
||||
existing &&
|
||||
(existing as any).__contentSig === contentSig &&
|
||||
relayoutInstanceNode(pixi, existing, inst, viewport)
|
||||
) {
|
||||
(existing as any).__sig = sig;
|
||||
continue;
|
||||
}
|
||||
@@ -449,6 +467,7 @@ function syncNodes(
|
||||
}
|
||||
const node = createInstanceNode(pixi, inst, size, viewport);
|
||||
if (!node) continue;
|
||||
(node as any).__contentSig = contentSig;
|
||||
(node as any).__sig = sig;
|
||||
nodes.set(inst.id, node);
|
||||
root.addChild(node);
|
||||
@@ -1720,7 +1739,12 @@ function redrawLightningVfx(
|
||||
}
|
||||
}
|
||||
|
||||
function instanceSig(inst: EffectInstance, viewport: { x: number; y: number; w: number; h: number }): string {
|
||||
function viewportSig(viewport: { x: number; y: number; w: number; h: number }): string {
|
||||
return `${Math.round(viewport.x)}:${Math.round(viewport.y)}:${Math.round(viewport.w)}:${Math.round(viewport.h)}`;
|
||||
}
|
||||
|
||||
/** Сигнатура содержимого инстанса без viewport — для in-place relayout при зуме/пане. */
|
||||
function instanceContentSig(inst: EffectInstance): string {
|
||||
if (inst.type === 'fog') {
|
||||
const last = inst.points[inst.points.length - 1];
|
||||
const lx = last ? Math.round(last.x * 1000) : 0;
|
||||
@@ -1741,7 +1765,7 @@ function instanceSig(inst: EffectInstance, viewport: { x: number; y: number; w:
|
||||
}
|
||||
if (inst.type === 'water') {
|
||||
const hp = hashWaterStroke(inst);
|
||||
return `water:${inst.points.length}:${hp}:${Math.round(inst.radiusN * 1000)}:${Math.round(inst.opacity * 1000)}:${Math.round(viewport.w)}:${Math.round(viewport.h)}`;
|
||||
return `water:${inst.points.length}:${hp}:${Math.round(inst.radiusN * 1000)}:${Math.round(inst.opacity * 1000)}`;
|
||||
}
|
||||
if (inst.type === 'lightning') {
|
||||
return `lt:${Math.round(inst.end.x * 1000)}:${Math.round(inst.end.y * 1000)}:${Math.round(inst.widthN * 1000)}`;
|
||||
@@ -1773,6 +1797,165 @@ function instanceSig(inst: EffectInstance, viewport: { x: number; y: number; w:
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Пересчитать экранные координаты/размер под новый contentRect (зум/пан).
|
||||
* false — нужна полная пересборка ноды.
|
||||
*/
|
||||
function relayoutInstanceNode(
|
||||
pixi: any,
|
||||
node: any,
|
||||
inst: EffectInstance,
|
||||
viewport: { x: number; y: number; w: number; h: number },
|
||||
): boolean {
|
||||
const { x: vx, y: vy, w, h } = viewport;
|
||||
const minDim = Math.min(w, h);
|
||||
|
||||
if (inst.type === 'fog') {
|
||||
const r = inst.radiusN * minDim;
|
||||
const fogSize = Math.max(4, r * 2);
|
||||
const children = node.children ?? [];
|
||||
let pi = 0;
|
||||
for (const child of children) {
|
||||
const p = inst.points[pi];
|
||||
pi += 1;
|
||||
if (!p) continue;
|
||||
const fx = (child as any).__fx ?? {};
|
||||
fx.bx = vx + p.x * w;
|
||||
fx.by = vy + p.y * h;
|
||||
fx.w0 = fogSize;
|
||||
fx.h0 = fogSize;
|
||||
(child as any).__fx = fx;
|
||||
child.x = fx.bx;
|
||||
child.y = fx.by;
|
||||
child.width = fogSize;
|
||||
child.height = fogSize;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (inst.type === 'fire') {
|
||||
const r = inst.radiusN * minDim;
|
||||
const flameWidth = Math.max(r * 3.2, minDim * 0.05);
|
||||
const flameHeight = flameWidth / GROUND_FIRE_VFX_FRAME_ASPECT;
|
||||
const children = node.children ?? [];
|
||||
let pi = 0;
|
||||
for (const child of children) {
|
||||
const p = inst.points[pi];
|
||||
pi += 1;
|
||||
if (!p) continue;
|
||||
const fx = (child as any).__fx ?? {};
|
||||
fx.bx = vx + p.x * w;
|
||||
fx.by = vy + p.y * h;
|
||||
fx.w0 = flameWidth;
|
||||
fx.h0 = flameHeight;
|
||||
(child as any).__fx = fx;
|
||||
child.x = fx.bx;
|
||||
child.y = fx.by;
|
||||
child.width = flameWidth;
|
||||
child.height = flameHeight;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (inst.type === 'rain') {
|
||||
const r = inst.radiusN * minDim;
|
||||
const rainWidth = Math.max(r * 3.8, minDim * 0.08);
|
||||
const rainHeight = rainWidth / RAIN_VFX_FRAME_ASPECT;
|
||||
const children = node.children ?? [];
|
||||
let pi = 0;
|
||||
for (const child of children) {
|
||||
const p = inst.points[pi];
|
||||
pi += 1;
|
||||
if (!p) continue;
|
||||
const fx = (child as any).__fx ?? {};
|
||||
fx.bx = vx + p.x * w;
|
||||
fx.by = vy + p.y * h;
|
||||
fx.w0 = rainWidth;
|
||||
fx.h0 = rainHeight;
|
||||
(child as any).__fx = fx;
|
||||
child.x = fx.bx;
|
||||
child.y = fx.by;
|
||||
child.width = rainWidth;
|
||||
child.height = rainHeight;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (inst.type === 'water') {
|
||||
const fx = (node as any).__fx;
|
||||
if (fx?.kind === 'waterDraft' && fx.g) {
|
||||
const halfW = Math.max(1.5, inst.radiusN * minDim);
|
||||
redrawWaterDraft(fx.g, inst, viewport, halfW);
|
||||
return true;
|
||||
}
|
||||
// Заливка воды строится в texture/mask под размер viewport — проще пересоздать.
|
||||
return false;
|
||||
}
|
||||
|
||||
if (inst.type === 'lightning') {
|
||||
const life = Math.max(1, inst.lifetimeMs);
|
||||
const t = Math.max(0, Date.now() - inst.createdAtMs);
|
||||
redrawLightningVfx(node, inst, viewport, t, life);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (inst.type === 'sunbeam') {
|
||||
const life = Math.max(1, inst.lifetimeMs);
|
||||
const t = Math.max(0, Date.now() - inst.createdAtMs);
|
||||
redrawPulseDischargeVfx(node, inst, viewport, t, life);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (inst.type === 'poisonCloud') {
|
||||
const life = Math.max(1, inst.lifetimeMs);
|
||||
const t = Math.max(0, Date.now() - inst.createdAtMs);
|
||||
redrawPoisonCloud(node, inst, viewport, t, life);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (inst.type === 'freeze') {
|
||||
const fx = (node as any).__fx ?? {};
|
||||
if (fx.vw !== viewport.w || fx.vh !== viewport.h) {
|
||||
node.texture = getFreezeScreenTexture(pixi, inst.seed, viewport);
|
||||
fx.vw = viewport.w;
|
||||
fx.vh = viewport.h;
|
||||
(node as any).__fx = fx;
|
||||
}
|
||||
node.x = viewport.x;
|
||||
node.y = viewport.y;
|
||||
node.width = viewport.w;
|
||||
node.height = viewport.h;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (inst.type === 'darkness') {
|
||||
const fx = (node as any).__fx ?? {};
|
||||
if (fx.vw !== viewport.w || fx.vh !== viewport.h) {
|
||||
node.texture = getDarknessScreenTexture(pixi, inst.seed, viewport);
|
||||
fx.vw = viewport.w;
|
||||
fx.vh = viewport.h;
|
||||
(node as any).__fx = fx;
|
||||
}
|
||||
node.x = viewport.x;
|
||||
node.y = viewport.y;
|
||||
node.width = viewport.w;
|
||||
node.height = viewport.h;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (inst.type === 'scorch' || inst.type === 'ice' || inst.type === 'shadow') {
|
||||
node.x = vx + inst.at.x * w;
|
||||
node.y = vy + inst.at.y * h;
|
||||
const r = inst.radiusN * minDim;
|
||||
const texW = node.texture?.width ?? node.width ?? 1;
|
||||
const scale = r / Math.max(1, texW * 0.5);
|
||||
node.scale?.set?.(scale, scale);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function hash01(seed: number, n: number): number {
|
||||
// Дешёвый детерминированный шум 0..1 (без Math.random).
|
||||
let x = (seed ^ (n * 374761393)) >>> 0;
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { ipcChannels } from '../../../shared/ipc/contracts';
|
||||
import type { SceneViewEvent, SceneViewState } from '../../../shared/types';
|
||||
import { getDndApi } from '../dndApi';
|
||||
|
||||
export function useSceneViewState(): readonly [
|
||||
SceneViewState | null,
|
||||
{ dispatch: (event: SceneViewEvent) => Promise<void> },
|
||||
] {
|
||||
const api = getDndApi();
|
||||
const [state, setState] = useState<SceneViewState | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
void api.invoke(ipcChannels.sceneView.getState, {}).then((r) => {
|
||||
setState(r.state);
|
||||
});
|
||||
return api.on(ipcChannels.sceneView.stateChanged, ({ state: next }) => {
|
||||
setState(next);
|
||||
});
|
||||
}, [api]);
|
||||
|
||||
return [
|
||||
state,
|
||||
{
|
||||
dispatch: async (event) => {
|
||||
await api.invoke(ipcChannels.sceneView.dispatch, { event });
|
||||
},
|
||||
},
|
||||
] as const;
|
||||
}
|
||||
Reference in New Issue
Block a user