From eb127c11c2222fd5b1d9b93a43533a6f6f903ac7 Mon Sep 17 00:00:00 2001 From: Ivan Fontosh Date: Thu, 23 Jul 2026 14:08:19 +0800 Subject: [PATCH] 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 --- app/main/index.ts | 26 +++ app/main/sceneView/sceneViewStore.test.ts | 67 +++++++ app/main/sceneView/sceneViewStore.ts | 64 ++++++ app/renderer/control/ControlApp.tsx | 153 +++++++++++++- app/renderer/control/ControlScenePreview.tsx | 12 +- app/renderer/shared/PresentationView.tsx | 3 + app/renderer/shared/RotatedImage.tsx | 42 +++- .../effects/PxiEffectsOverlay.pointer.test.ts | 8 + .../shared/effects/PxiEffectsOverlay.tsx | 189 +++++++++++++++++- .../shared/sceneView/useSceneViewState.ts | 31 +++ app/shared/ipc/contracts.ts | 17 ++ app/shared/types/index.ts | 1 + app/shared/types/sceneView.ts | 94 +++++++++ 13 files changed, 688 insertions(+), 19 deletions(-) create mode 100644 app/main/sceneView/sceneViewStore.test.ts create mode 100644 app/main/sceneView/sceneViewStore.ts create mode 100644 app/renderer/shared/sceneView/useSceneViewState.ts create mode 100644 app/shared/types/sceneView.ts diff --git a/app/main/index.ts b/app/main/index.ts index 51a6ee9..17bc341 100644 --- a/app/main/index.ts +++ b/app/main/index.ts @@ -26,6 +26,7 @@ import { LicenseService } from './license/licenseService'; import { MaterialsOverlayStore } from './materials/materialsOverlayStore'; import { NpcsOverlayStore } from './npcs/npcsOverlayStore'; import { ZipProjectStore } from './project/zipStore'; +import { SceneViewStore } from './sceneView/sceneViewStore'; import { registerDndAssetProtocol } from './protocol/dndAssetProtocol'; import { installAutoUpdater } from './update/installAutoUpdater'; import { getAppSemanticVersion, getOptionalBuildNumber } from './versionInfo'; @@ -146,6 +147,7 @@ function installAppMenuForSession(): void { const effectsStore = new EffectsStore(); const sceneDarknessStore = new SceneDarknessStore(); +const sceneViewStore = new SceneViewStore(); const videoStore = new VideoPlaybackStore(); const materialsOverlayStore = new MaterialsOverlayStore(); const npcsOverlayStore = new NpcsOverlayStore(); @@ -196,6 +198,13 @@ function emitSceneDarknessState(): void { } } +function emitSceneViewState(): void { + const state = sceneViewStore.getState(); + for (const win of BrowserWindow.getAllWindows()) { + win.webContents.send(ipcChannels.sceneView.stateChanged, { state }); + } +} + function syncSceneDarknessForProject(project: Project): void { const cacheKey = project.currentGraphNodeId ?? project.currentSceneId ?? null; const scene = project.currentSceneId ? project.scenes[project.currentSceneId] : undefined; @@ -438,6 +447,8 @@ async function main() { }); registerHandler(ipcChannels.project.open, async ({ projectId }) => { const project = await projectStore.openProjectById(projectId); + sceneViewStore.reset(); + emitSceneViewState(); emitSessionState(); return { project }; }); @@ -447,10 +458,12 @@ async function main() { materialsOverlayStore.clear(); npcsOverlayStore.clear(); sceneDarknessStore.resetSession(); + sceneViewStore.reset(); emitEffectsState(); emitMaterialsOverlayState(); emitNpcsOverlayState(); emitSceneDarknessState(); + emitSceneViewState(); emitSessionState(); return { ok: true }; }); @@ -466,12 +479,14 @@ async function main() { effectsStore.clear(); materialsOverlayStore.clear(); npcsOverlayStore.clear(); + sceneViewStore.reset(); const project = projectStore.getOpenProject(); if (project) syncSceneDarknessForProject(project); emitEffectsState(); emitMaterialsOverlayState(); emitNpcsOverlayState(); emitSceneDarknessState(); + emitSceneViewState(); emitSessionState(); return { currentSceneId: projectStore.getOpenProject()?.currentSceneId ?? null }; }); @@ -487,12 +502,14 @@ async function main() { effectsStore.clear(); materialsOverlayStore.clear(); npcsOverlayStore.clear(); + sceneViewStore.reset(); const project = projectStore.getOpenProject(); if (project) syncSceneDarknessForProject(project); emitEffectsState(); emitMaterialsOverlayState(); emitNpcsOverlayState(); emitSceneDarknessState(); + emitSceneViewState(); emitSessionState(); const p = projectStore.getOpenProject(); return { @@ -1082,6 +1099,15 @@ async function main() { return { ok: true }; }); + registerHandler(ipcChannels.sceneView.getState, () => { + return { state: sceneViewStore.getState() }; + }); + registerHandler(ipcChannels.sceneView.dispatch, ({ event }) => { + sceneViewStore.dispatch(event); + emitSceneViewState(); + return { ok: true }; + }); + registerHandler(ipcChannels.video.getState, () => { return { state: videoStore.getState() }; }); diff --git a/app/main/sceneView/sceneViewStore.test.ts b/app/main/sceneView/sceneViewStore.test.ts new file mode 100644 index 0000000..2b9d40c --- /dev/null +++ b/app/main/sceneView/sceneViewStore.test.ts @@ -0,0 +1,67 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { + DEFAULT_SCENE_VIEW_CAMERA, + sceneViewPanBy, + sceneViewZoomAt, +} from '../../shared/types/sceneView'; + +import { SceneViewStore } from './sceneViewStore'; + +describe('SceneViewStore', () => { + it('resets to default camera', () => { + const store = new SceneViewStore(); + store.dispatch({ kind: 'set', camera: { scale: 2, ox: 0.2, oy: 0.8 } }); + const next = store.dispatch({ kind: 'reset' }); + assert.equal(next.scale, 1); + assert.equal(next.ox, 0.5); + assert.equal(next.oy, 0.5); + }); + + it('clamps scale and origin', () => { + const store = new SceneViewStore(); + const next = store.dispatch({ kind: 'set', camera: { scale: 99, ox: -1, oy: 2 } }); + assert.equal(next.scale, 8); + assert.equal(next.ox, 0); + assert.equal(next.oy, 1); + }); +}); + +describe('sceneViewZoomAt / panBy', () => { + it('zooms toward cursor and keeps that content point under cursor', () => { + const hostW = 1000; + const hostH = 500; + const containW = 800; + const containH = 400; + const hostX = 700; + const hostY = 250; + const next = sceneViewZoomAt(DEFAULT_SCENE_VIEW_CAMERA, { + hostW, + hostH, + containW, + containH, + hostX, + hostY, + factor: 2, + }); + assert.ok(next.scale > 1.5); + const displayW = containW * next.scale; + const displayH = containH * next.scale; + const left = hostW / 2 - next.ox * displayW; + const top = hostH / 2 - next.oy * displayH; + const ix = (hostX - left) / displayW; + const iy = (hostY - top) / displayH; + // At scale=1 contain is centered; cursor was at content x=(700-100)/800=0.75 + assert.ok(Math.abs(ix - 0.75) < 1e-6); + assert.ok(Math.abs(iy - 0.5) < 1e-6); + }); + + it('pans in host pixels', () => { + const cam = { scale: 2, ox: 0.5, oy: 0.5 }; + const next = sceneViewPanBy(cam, { containW: 400, containH: 200, dx: 80, dy: 0 }); + // displayW=800; dx=80 → ox decreases by 0.1 + assert.ok(Math.abs(next.ox - 0.4) < 1e-6); + assert.equal(next.oy, 0.5); + }); +}); diff --git a/app/main/sceneView/sceneViewStore.ts b/app/main/sceneView/sceneViewStore.ts new file mode 100644 index 0000000..cab2395 --- /dev/null +++ b/app/main/sceneView/sceneViewStore.ts @@ -0,0 +1,64 @@ +import { + clampSceneViewCamera, + DEFAULT_SCENE_VIEW_CAMERA, + type SceneViewCamera, + type SceneViewEvent, + type SceneViewState, +} from '../../shared/types'; + +function emptyState(): SceneViewState { + return { + revision: 1, + ...DEFAULT_SCENE_VIEW_CAMERA, + }; +} + +export class SceneViewStore { + private state: SceneViewState = emptyState(); + + getState(): SceneViewState { + return this.state; + } + + reset(): SceneViewState { + if ( + this.state.scale === 1 && + this.state.ox === 0.5 && + this.state.oy === 0.5 + ) { + return this.state; + } + this.state = { + revision: this.state.revision + 1, + ...DEFAULT_SCENE_VIEW_CAMERA, + }; + return this.state; + } + + dispatch(event: SceneViewEvent): SceneViewState { + switch (event.kind) { + case 'reset': + return this.reset(); + case 'set': { + const camera: SceneViewCamera = clampSceneViewCamera(event.camera); + if ( + camera.scale === this.state.scale && + camera.ox === this.state.ox && + camera.oy === this.state.oy + ) { + return this.state; + } + this.state = { + revision: this.state.revision + 1, + ...camera, + }; + return this.state; + } + default: { + const _exhaustive: never = event; + void _exhaustive; + return this.state; + } + } + } +} diff --git a/app/renderer/control/ControlApp.tsx b/app/renderer/control/ControlApp.tsx index 7670429..412c13d 100644 --- a/app/renderer/control/ControlApp.tsx +++ b/app/renderer/control/ControlApp.tsx @@ -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(null); const [materialsOverlay, materialsApi] = useMaterialsOverlayState(); const [npcsOverlay, npcsApi] = useNpcsOverlayState(); const [session, setSession] = useState(null); @@ -139,7 +147,13 @@ export function ControlApp() { const allowCampaignAudioRef = useRef(true); const audioUnmountRef = useRef(false); const previewHostRef = useRef(null); + const previewFrameRef = useRef(null); const previewVideoRef = useRef(null); + const sceneViewRef = useRef(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(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() {
{isVideoPreviewScene ?
{t('control.videoBrushHint')}
: null}
-
+
@@ -1577,6 +1678,9 @@ export function ControlApp() { />
{ + 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 }); } diff --git a/app/renderer/control/ControlScenePreview.tsx b/app/renderer/control/ControlScenePreview.tsx index 9f3ae5d..3fdf77c 100644 --- a/app/renderer/control/ControlScenePreview.tsx +++ b/app/renderer/control/ControlScenePreview.tsx @@ -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; + 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 (
{url && scene?.previewAssetType === 'image' ? ( - + ) : url && isVideo ? (
diff --git a/app/renderer/shared/RotatedImage.tsx b/app/renderer/shared/RotatedImage.tsx index 368e8df..ed0c4cd 100644 --- a/app/renderer/shared/RotatedImage.tsx +++ b/app/renderer/shared/RotatedImage.tsx @@ -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['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(); const [imgSize, setImgSize] = useState<{ w: number; h: number } | null>(null); const imgRef = useRef(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 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 (
@@ -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)`, }} diff --git a/app/renderer/shared/effects/PxiEffectsOverlay.pointer.test.ts b/app/renderer/shared/effects/PxiEffectsOverlay.pointer.test.ts index f68f064..89e08b5 100644 --- a/app/renderer/shared/effects/PxiEffectsOverlay.pointer.test.ts +++ b/app/renderer/shared/effects/PxiEffectsOverlay.pointer.test.ts @@ -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\)\}/); +}); diff --git a/app/renderer/shared/effects/PxiEffectsOverlay.tsx b/app/renderer/shared/effects/PxiEffectsOverlay.tsx index 75eda3a..b9f6ea5 100644 --- a/app/renderer/shared/effects/PxiEffectsOverlay.tsx +++ b/app/renderer/shared/effects/PxiEffectsOverlay.tsx @@ -388,8 +388,15 @@ export const PixiEffectsOverlay = forwardRef(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; diff --git a/app/renderer/shared/sceneView/useSceneViewState.ts b/app/renderer/shared/sceneView/useSceneViewState.ts new file mode 100644 index 0000000..83bab55 --- /dev/null +++ b/app/renderer/shared/sceneView/useSceneViewState.ts @@ -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 }, +] { + const api = getDndApi(); + const [state, setState] = useState(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; +} diff --git a/app/shared/ipc/contracts.ts b/app/shared/ipc/contracts.ts index 6502f97..b66af48 100644 --- a/app/shared/ipc/contracts.ts +++ b/app/shared/ipc/contracts.ts @@ -20,6 +20,8 @@ import type { SceneDarknessEvent, SceneDarknessState, SceneId, + SceneViewEvent, + SceneViewState, VideoPlaybackEvent, VideoPlaybackState, } from '../types'; @@ -143,6 +145,11 @@ export const ipcChannels = { dispatch: 'sceneDarkness.dispatch', stateChanged: 'sceneDarkness.stateChanged', }, + sceneView: { + getState: 'sceneView.getState', + dispatch: 'sceneView.dispatch', + stateChanged: 'sceneView.stateChanged', + }, video: { getState: 'video.getState', dispatch: 'video.dispatch', @@ -203,6 +210,7 @@ export type IpcEventMap = { [ipcChannels.materialsOverlay.stateChanged]: { state: MaterialsOverlayState }; [ipcChannels.npcsOverlay.stateChanged]: { state: NpcsOverlayState }; [ipcChannels.sceneDarkness.stateChanged]: { state: SceneDarknessState }; + [ipcChannels.sceneView.stateChanged]: { state: SceneViewState }; [ipcChannels.video.stateChanged]: { state: VideoPlaybackState }; [ipcChannels.license.statusChanged]: { snapshot: LicenseSnapshot }; [ipcChannels.windows.multiWindowStateChanged]: { open: boolean }; @@ -593,6 +601,14 @@ export type IpcInvokeMap = { req: { event: SceneDarknessEvent }; res: { ok: true }; }; + [ipcChannels.sceneView.getState]: { + req: Record; + res: { state: SceneViewState }; + }; + [ipcChannels.sceneView.dispatch]: { + req: { event: SceneViewEvent }; + res: { ok: true }; + }; [ipcChannels.video.getState]: { req: Record; res: { state: VideoPlaybackState }; @@ -630,6 +646,7 @@ export type LegacyIpcEventMap = { [ipcChannels.materialsOverlay.stateChanged]: { state: MaterialsOverlayState }; [ipcChannels.npcsOverlay.stateChanged]: { state: NpcsOverlayState }; [ipcChannels.sceneDarkness.stateChanged]: { state: SceneDarknessState }; + [ipcChannels.sceneView.stateChanged]: { state: SceneViewState }; [ipcChannels.video.stateChanged]: { state: VideoPlaybackState }; [ipcChannels.license.statusChanged]: Record; }; diff --git a/app/shared/types/index.ts b/app/shared/types/index.ts index ec3d074..5fdd0e2 100644 --- a/app/shared/types/index.ts +++ b/app/shared/types/index.ts @@ -4,4 +4,5 @@ export * from './ids'; export * from './materials'; export * from './npcs'; export * from './sceneDarkness'; +export * from './sceneView'; export * from './videoPlayback'; diff --git a/app/shared/types/sceneView.ts b/app/shared/types/sceneView.ts new file mode 100644 index 0000000..5a6da03 --- /dev/null +++ b/app/shared/types/sceneView.ts @@ -0,0 +1,94 @@ +/** Камера вида сцены (общая для Control и Presentation). */ + +export type SceneViewCamera = { + /** Множитель поверх contain-fit; 1 = вся картинка влезает. */ + scale: number; + /** Точка контента (0..1), которая держится в центре viewport. */ + ox: number; + oy: number; +}; + +export type SceneViewState = { + revision: number; +} & SceneViewCamera; + +export type SceneViewEvent = + | { kind: 'reset' } + | { kind: 'set'; camera: SceneViewCamera }; + +export const DEFAULT_SCENE_VIEW_CAMERA: SceneViewCamera = { + scale: 1, + ox: 0.5, + oy: 0.5, +}; + +export const SCENE_VIEW_MIN_SCALE = 1; +export const SCENE_VIEW_MAX_SCALE = 8; + +export function clampSceneViewCamera(camera: SceneViewCamera): SceneViewCamera { + const scale = Math.max( + SCENE_VIEW_MIN_SCALE, + Math.min(SCENE_VIEW_MAX_SCALE, Number.isFinite(camera.scale) ? camera.scale : 1), + ); + if (scale <= 1.001) { + return { ...DEFAULT_SCENE_VIEW_CAMERA }; + } + const ox = Math.max(0, Math.min(1, Number.isFinite(camera.ox) ? camera.ox : 0.5)); + const oy = Math.max(0, Math.min(1, Number.isFinite(camera.oy) ? camera.oy : 0.5)); + return { scale, ox, oy }; +} + +/** + * Зум относительно точки курсора в координатах хоста (px). + * `containW/H` — размер contain-fit без зума. + */ +export function sceneViewZoomAt( + camera: SceneViewCamera, + args: { + hostW: number; + hostH: number; + containW: number; + containH: number; + hostX: number; + hostY: number; + factor: number; + }, +): SceneViewCamera { + const hostW = Math.max(1, args.hostW); + const hostH = Math.max(1, args.hostH); + const containW = Math.max(1, args.containW); + const containH = Math.max(1, args.containH); + const cur = clampSceneViewCamera(camera); + const displayW = containW * cur.scale; + const displayH = containH * cur.scale; + const left = hostW / 2 - cur.ox * displayW; + const top = hostH / 2 - cur.oy * displayH; + const ix = (args.hostX - left) / Math.max(1e-6, displayW); + const iy = (args.hostY - top) / Math.max(1e-6, displayH); + const nextScale = Math.max( + SCENE_VIEW_MIN_SCALE, + Math.min(SCENE_VIEW_MAX_SCALE, cur.scale * args.factor), + ); + if (nextScale <= 1.001) return { ...DEFAULT_SCENE_VIEW_CAMERA }; + const nextDisplayW = containW * nextScale; + const nextDisplayH = containH * nextScale; + const ox = ix - (args.hostX - hostW / 2) / Math.max(1e-6, nextDisplayW); + const oy = iy - (args.hostY - hostH / 2) / Math.max(1e-6, nextDisplayH); + return clampSceneViewCamera({ scale: nextScale, ox, oy }); +} + +/** Пан на `dx/dy` пикселей хоста (тянуть картинку в сторону движения указателя). */ +export function sceneViewPanBy( + camera: SceneViewCamera, + args: { containW: number; containH: number; dx: number; dy: number }, +): SceneViewCamera { + const cur = clampSceneViewCamera(camera); + if (cur.scale <= 1.001) return { ...DEFAULT_SCENE_VIEW_CAMERA }; + const displayW = Math.max(1e-6, args.containW * cur.scale); + const displayH = Math.max(1e-6, args.containH * cur.scale); + return clampSceneViewCamera({ + scale: cur.scale, + ox: cur.ox - args.dx / displayW, + oy: cur.oy - args.dy / displayH, + }); +}