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:
Ivan Fontosh
2026-07-23 14:08:19 +08:00
parent 2c0e6fbf09
commit eb127c11c2
13 changed files with 688 additions and 19 deletions
+3
View File
@@ -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>
+32 -10
View File
@@ -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;
}