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:
@@ -26,6 +26,7 @@ import { LicenseService } from './license/licenseService';
|
|||||||
import { MaterialsOverlayStore } from './materials/materialsOverlayStore';
|
import { MaterialsOverlayStore } from './materials/materialsOverlayStore';
|
||||||
import { NpcsOverlayStore } from './npcs/npcsOverlayStore';
|
import { NpcsOverlayStore } from './npcs/npcsOverlayStore';
|
||||||
import { ZipProjectStore } from './project/zipStore';
|
import { ZipProjectStore } from './project/zipStore';
|
||||||
|
import { SceneViewStore } from './sceneView/sceneViewStore';
|
||||||
import { registerDndAssetProtocol } from './protocol/dndAssetProtocol';
|
import { registerDndAssetProtocol } from './protocol/dndAssetProtocol';
|
||||||
import { installAutoUpdater } from './update/installAutoUpdater';
|
import { installAutoUpdater } from './update/installAutoUpdater';
|
||||||
import { getAppSemanticVersion, getOptionalBuildNumber } from './versionInfo';
|
import { getAppSemanticVersion, getOptionalBuildNumber } from './versionInfo';
|
||||||
@@ -146,6 +147,7 @@ function installAppMenuForSession(): void {
|
|||||||
|
|
||||||
const effectsStore = new EffectsStore();
|
const effectsStore = new EffectsStore();
|
||||||
const sceneDarknessStore = new SceneDarknessStore();
|
const sceneDarknessStore = new SceneDarknessStore();
|
||||||
|
const sceneViewStore = new SceneViewStore();
|
||||||
const videoStore = new VideoPlaybackStore();
|
const videoStore = new VideoPlaybackStore();
|
||||||
const materialsOverlayStore = new MaterialsOverlayStore();
|
const materialsOverlayStore = new MaterialsOverlayStore();
|
||||||
const npcsOverlayStore = new NpcsOverlayStore();
|
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 {
|
function syncSceneDarknessForProject(project: Project): void {
|
||||||
const cacheKey = project.currentGraphNodeId ?? project.currentSceneId ?? null;
|
const cacheKey = project.currentGraphNodeId ?? project.currentSceneId ?? null;
|
||||||
const scene = project.currentSceneId ? project.scenes[project.currentSceneId] : undefined;
|
const scene = project.currentSceneId ? project.scenes[project.currentSceneId] : undefined;
|
||||||
@@ -438,6 +447,8 @@ async function main() {
|
|||||||
});
|
});
|
||||||
registerHandler(ipcChannels.project.open, async ({ projectId }) => {
|
registerHandler(ipcChannels.project.open, async ({ projectId }) => {
|
||||||
const project = await projectStore.openProjectById(projectId);
|
const project = await projectStore.openProjectById(projectId);
|
||||||
|
sceneViewStore.reset();
|
||||||
|
emitSceneViewState();
|
||||||
emitSessionState();
|
emitSessionState();
|
||||||
return { project };
|
return { project };
|
||||||
});
|
});
|
||||||
@@ -447,10 +458,12 @@ async function main() {
|
|||||||
materialsOverlayStore.clear();
|
materialsOverlayStore.clear();
|
||||||
npcsOverlayStore.clear();
|
npcsOverlayStore.clear();
|
||||||
sceneDarknessStore.resetSession();
|
sceneDarknessStore.resetSession();
|
||||||
|
sceneViewStore.reset();
|
||||||
emitEffectsState();
|
emitEffectsState();
|
||||||
emitMaterialsOverlayState();
|
emitMaterialsOverlayState();
|
||||||
emitNpcsOverlayState();
|
emitNpcsOverlayState();
|
||||||
emitSceneDarknessState();
|
emitSceneDarknessState();
|
||||||
|
emitSceneViewState();
|
||||||
emitSessionState();
|
emitSessionState();
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
});
|
});
|
||||||
@@ -466,12 +479,14 @@ async function main() {
|
|||||||
effectsStore.clear();
|
effectsStore.clear();
|
||||||
materialsOverlayStore.clear();
|
materialsOverlayStore.clear();
|
||||||
npcsOverlayStore.clear();
|
npcsOverlayStore.clear();
|
||||||
|
sceneViewStore.reset();
|
||||||
const project = projectStore.getOpenProject();
|
const project = projectStore.getOpenProject();
|
||||||
if (project) syncSceneDarknessForProject(project);
|
if (project) syncSceneDarknessForProject(project);
|
||||||
emitEffectsState();
|
emitEffectsState();
|
||||||
emitMaterialsOverlayState();
|
emitMaterialsOverlayState();
|
||||||
emitNpcsOverlayState();
|
emitNpcsOverlayState();
|
||||||
emitSceneDarknessState();
|
emitSceneDarknessState();
|
||||||
|
emitSceneViewState();
|
||||||
emitSessionState();
|
emitSessionState();
|
||||||
return { currentSceneId: projectStore.getOpenProject()?.currentSceneId ?? null };
|
return { currentSceneId: projectStore.getOpenProject()?.currentSceneId ?? null };
|
||||||
});
|
});
|
||||||
@@ -487,12 +502,14 @@ async function main() {
|
|||||||
effectsStore.clear();
|
effectsStore.clear();
|
||||||
materialsOverlayStore.clear();
|
materialsOverlayStore.clear();
|
||||||
npcsOverlayStore.clear();
|
npcsOverlayStore.clear();
|
||||||
|
sceneViewStore.reset();
|
||||||
const project = projectStore.getOpenProject();
|
const project = projectStore.getOpenProject();
|
||||||
if (project) syncSceneDarknessForProject(project);
|
if (project) syncSceneDarknessForProject(project);
|
||||||
emitEffectsState();
|
emitEffectsState();
|
||||||
emitMaterialsOverlayState();
|
emitMaterialsOverlayState();
|
||||||
emitNpcsOverlayState();
|
emitNpcsOverlayState();
|
||||||
emitSceneDarknessState();
|
emitSceneDarknessState();
|
||||||
|
emitSceneViewState();
|
||||||
emitSessionState();
|
emitSessionState();
|
||||||
const p = projectStore.getOpenProject();
|
const p = projectStore.getOpenProject();
|
||||||
return {
|
return {
|
||||||
@@ -1082,6 +1099,15 @@ async function main() {
|
|||||||
return { ok: true };
|
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, () => {
|
registerHandler(ipcChannels.video.getState, () => {
|
||||||
return { state: videoStore.getState() };
|
return { state: videoStore.getState() };
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,12 @@ import {
|
|||||||
isNodeInSideStoryline,
|
isNodeInSideStoryline,
|
||||||
listSideStoryStarts,
|
listSideStoryStarts,
|
||||||
} from '../../shared/graph/sceneGraphLineage';
|
} 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 { useEditorI18n } from '../editor/i18n/EditorI18nContext';
|
||||||
import { isSceneDescriptionEmpty } from '../editor/sceneDescriptionHtml';
|
import { isSceneDescriptionEmpty } from '../editor/sceneDescriptionHtml';
|
||||||
import { getDndApi } from '../shared/dndApi';
|
import { getDndApi } from '../shared/dndApi';
|
||||||
@@ -27,6 +32,7 @@ import { useMaterialsOverlayState } from '../shared/materials/useMaterialsOverla
|
|||||||
import { NpcsSceneOverlay } from '../shared/npcs/NpcsSceneOverlay';
|
import { NpcsSceneOverlay } from '../shared/npcs/NpcsSceneOverlay';
|
||||||
import { useNpcsOverlayState } from '../shared/npcs/useNpcsOverlayState';
|
import { useNpcsOverlayState } from '../shared/npcs/useNpcsOverlayState';
|
||||||
import { SceneOverlayHost } from '../shared/sceneOverlay/SceneOverlayHost';
|
import { SceneOverlayHost } from '../shared/sceneOverlay/SceneOverlayHost';
|
||||||
|
import { useSceneViewState } from '../shared/sceneView/useSceneViewState';
|
||||||
import { Button } from '../shared/ui/controls';
|
import { Button } from '../shared/ui/controls';
|
||||||
import { Surface } from '../shared/ui/Surface';
|
import { Surface } from '../shared/ui/Surface';
|
||||||
import { useAssetUrl } from '../shared/useAssetImageUrl';
|
import { useAssetUrl } from '../shared/useAssetImageUrl';
|
||||||
@@ -113,6 +119,8 @@ export function ControlApp() {
|
|||||||
const [fxState, fx] = useEffectsState();
|
const [fxState, fx] = useEffectsState();
|
||||||
const [effectsSfxGainUi, setEffectsSfxGainUi] = useState(() => getEffectsSfxGain());
|
const [effectsSfxGainUi, setEffectsSfxGainUi] = useState(() => getEffectsSfxGain());
|
||||||
const [sdState, sd] = useSceneDarknessState();
|
const [sdState, sd] = useSceneDarknessState();
|
||||||
|
const [sceneView, sceneViewApi] = useSceneViewState();
|
||||||
|
const [sceneViewDraft, setSceneViewDraft] = useState<SceneViewCamera | null>(null);
|
||||||
const [materialsOverlay, materialsApi] = useMaterialsOverlayState();
|
const [materialsOverlay, materialsApi] = useMaterialsOverlayState();
|
||||||
const [npcsOverlay, npcsApi] = useNpcsOverlayState();
|
const [npcsOverlay, npcsApi] = useNpcsOverlayState();
|
||||||
const [session, setSession] = useState<SessionState | null>(null);
|
const [session, setSession] = useState<SessionState | null>(null);
|
||||||
@@ -139,7 +147,13 @@ export function ControlApp() {
|
|||||||
const allowCampaignAudioRef = useRef<boolean>(true);
|
const allowCampaignAudioRef = useRef<boolean>(true);
|
||||||
const audioUnmountRef = useRef(false);
|
const audioUnmountRef = useRef(false);
|
||||||
const previewHostRef = useRef<HTMLDivElement | null>(null);
|
const previewHostRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const previewFrameRef = useRef<HTMLDivElement | null>(null);
|
||||||
const previewVideoRef = useRef<HTMLVideoElement | 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<{
|
const brushRef = useRef<{
|
||||||
tool:
|
tool:
|
||||||
| 'fog'
|
| 'fog'
|
||||||
@@ -649,6 +663,84 @@ export function ControlApp() {
|
|||||||
return () => ro.disconnect();
|
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 } {
|
function audioStatus(group: 'scene' | 'campaign', assetId: string): { label: string; detail?: string } {
|
||||||
const el =
|
const el =
|
||||||
group === 'scene'
|
group === 'scene'
|
||||||
@@ -1541,11 +1633,20 @@ export function ControlApp() {
|
|||||||
<div className={styles.spacer10} />
|
<div className={styles.spacer10} />
|
||||||
{isVideoPreviewScene ? <div className={styles.videoHint}>{t('control.videoBrushHint')}</div> : null}
|
{isVideoPreviewScene ? <div className={styles.videoHint}>{t('control.videoBrushHint')}</div> : null}
|
||||||
<div className={styles.spacer10} />
|
<div className={styles.spacer10} />
|
||||||
<div className={styles.previewFrame}>
|
<div
|
||||||
|
ref={previewFrameRef}
|
||||||
|
className={styles.previewFrame}
|
||||||
|
title={
|
||||||
|
isVideoPreviewScene
|
||||||
|
? undefined
|
||||||
|
: 'Колесо — зум; перетаскивание СКМ/ПКМ или Space+ЛКМ — пан'
|
||||||
|
}
|
||||||
|
>
|
||||||
<div ref={previewHostRef} className={styles.previewHost}>
|
<div ref={previewHostRef} className={styles.previewHost}>
|
||||||
<ControlScenePreview
|
<ControlScenePreview
|
||||||
session={session}
|
session={session}
|
||||||
videoRef={previewVideoRef}
|
videoRef={previewVideoRef}
|
||||||
|
viewCamera={activeSceneViewCamera}
|
||||||
onContentRectChange={setPreviewContentRect}
|
onContentRectChange={setPreviewContentRect}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -1577,6 +1678,9 @@ export function ControlApp() {
|
|||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
className={styles.brushLayer}
|
className={styles.brushLayer}
|
||||||
|
onContextMenu={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
}}
|
||||||
onPointerEnter={(e) => {
|
onPointerEnter={(e) => {
|
||||||
const p = toNPoint(e);
|
const p = toNPoint(e);
|
||||||
if (!p) return;
|
if (!p) return;
|
||||||
@@ -1588,6 +1692,19 @@ export function ControlApp() {
|
|||||||
layoutBrushCursor();
|
layoutBrushCursor();
|
||||||
}}
|
}}
|
||||||
onPointerDown={(e) => {
|
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);
|
const p = toNPoint(e);
|
||||||
if (!p) return;
|
if (!p) return;
|
||||||
cursorPosRef.current = p;
|
cursorPosRef.current = p;
|
||||||
@@ -1622,6 +1739,22 @@ export function ControlApp() {
|
|||||||
pushDraftToPixi();
|
pushDraftToPixi();
|
||||||
}}
|
}}
|
||||||
onPointerMove={(e) => {
|
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);
|
const p = toNPoint(e);
|
||||||
if (!p) return;
|
if (!p) return;
|
||||||
cursorPosRef.current = p;
|
cursorPosRef.current = p;
|
||||||
@@ -1662,10 +1795,22 @@ export function ControlApp() {
|
|||||||
}
|
}
|
||||||
scheduleDraftRepaint();
|
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();
|
void commitStroke();
|
||||||
}}
|
}}
|
||||||
onPointerCancel={() => {
|
onPointerCancel={(e) => {
|
||||||
|
if (scenePanRef.current?.pointerId === e.pointerId) {
|
||||||
|
scenePanRef.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (brushRef.current?.tool === 'exploreBrush') {
|
if (brushRef.current?.tool === 'exploreBrush') {
|
||||||
void sd.dispatch({ kind: 'draft.set', draft: null });
|
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 { computeTimeSec } from '../../main/video/videoPlaybackStore';
|
||||||
import type { SessionState } from '../../shared/ipc/contracts';
|
import type { SessionState } from '../../shared/ipc/contracts';
|
||||||
|
import type { SceneViewCamera } from '../../shared/types';
|
||||||
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
|
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
|
||||||
import { RotatedImage } from '../shared/RotatedImage';
|
import { RotatedImage } from '../shared/RotatedImage';
|
||||||
import { useAssetUrl } from '../shared/useAssetImageUrl';
|
import { useAssetUrl } from '../shared/useAssetImageUrl';
|
||||||
@@ -12,6 +13,7 @@ import styles from './ControlScenePreview.module.css';
|
|||||||
type Props = {
|
type Props = {
|
||||||
session: SessionState | null;
|
session: SessionState | null;
|
||||||
videoRef: React.RefObject<HTMLVideoElement | null>;
|
videoRef: React.RefObject<HTMLVideoElement | null>;
|
||||||
|
viewCamera?: SceneViewCamera | null;
|
||||||
onContentRectChange?: (rect: { x: number; y: number; w: number; h: number }) => void;
|
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')}`;
|
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 { t } = useEditorI18n();
|
||||||
const [vp, video] = useVideoPlaybackState();
|
const [vp, video] = useVideoPlaybackState();
|
||||||
const scene =
|
const scene =
|
||||||
@@ -91,7 +93,13 @@ export function ControlScenePreview({ session, videoRef, onContentRectChange }:
|
|||||||
return (
|
return (
|
||||||
<div className={styles.root}>
|
<div className={styles.root}>
|
||||||
{url && scene?.previewAssetType === 'image' ? (
|
{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 ? (
|
) : url && isVideo ? (
|
||||||
<video
|
<video
|
||||||
ref={(el) => {
|
ref={(el) => {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { useMaterialsOverlayState } from './materials/useMaterialsOverlayState';
|
|||||||
import { NpcsSceneOverlay } from './npcs/NpcsSceneOverlay';
|
import { NpcsSceneOverlay } from './npcs/NpcsSceneOverlay';
|
||||||
import { useNpcsOverlayState } from './npcs/useNpcsOverlayState';
|
import { useNpcsOverlayState } from './npcs/useNpcsOverlayState';
|
||||||
import { SceneOverlayHost } from './sceneOverlay/SceneOverlayHost';
|
import { SceneOverlayHost } from './sceneOverlay/SceneOverlayHost';
|
||||||
|
import { useSceneViewState } from './sceneView/useSceneViewState';
|
||||||
import styles from './PresentationView.module.css';
|
import styles from './PresentationView.module.css';
|
||||||
import { RotatedImage } from './RotatedImage';
|
import { RotatedImage } from './RotatedImage';
|
||||||
import { useAssetUrl } from './useAssetImageUrl';
|
import { useAssetUrl } from './useAssetImageUrl';
|
||||||
@@ -34,6 +35,7 @@ export function PresentationView({
|
|||||||
}: PresentationViewProps) {
|
}: PresentationViewProps) {
|
||||||
const [fxState] = useEffectsState();
|
const [fxState] = useEffectsState();
|
||||||
const [sdState] = useSceneDarknessState();
|
const [sdState] = useSceneDarknessState();
|
||||||
|
const [sceneView] = useSceneViewState();
|
||||||
const [materialsOverlay] = useMaterialsOverlayState();
|
const [materialsOverlay] = useMaterialsOverlayState();
|
||||||
const [npcsOverlay] = useNpcsOverlayState();
|
const [npcsOverlay] = useNpcsOverlayState();
|
||||||
const [vp] = useVideoPlaybackState();
|
const [vp] = useVideoPlaybackState();
|
||||||
@@ -132,6 +134,7 @@ export function PresentationView({
|
|||||||
url={shownImageUrl}
|
url={shownImageUrl}
|
||||||
rotationDeg={rot}
|
rotationDeg={rot}
|
||||||
mode="contain"
|
mode="contain"
|
||||||
|
viewCamera={sceneView}
|
||||||
onContentRectChange={setContentRect}
|
onContentRectChange={setContentRect}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
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';
|
import styles from './RotatedImage.module.css';
|
||||||
|
|
||||||
type Mode = 'cover' | 'contain';
|
type Mode = 'cover' | 'contain';
|
||||||
@@ -13,6 +18,8 @@ type RotatedImageProps = {
|
|||||||
decoding?: React.ImgHTMLAttributes<HTMLImageElement>['decoding'];
|
decoding?: React.ImgHTMLAttributes<HTMLImageElement>['decoding'];
|
||||||
/** Высота/ширина полностью контролируются родителем. */
|
/** Высота/ширина полностью контролируются родителем. */
|
||||||
style?: React.CSSProperties;
|
style?: React.CSSProperties;
|
||||||
|
/** Зум/пан поверх contain/cover (только для mode=contain в сценах). */
|
||||||
|
viewCamera?: SceneViewCamera | null;
|
||||||
/** Прямоугольник видимого контента (contain/cover) внутри контейнера. */
|
/** Прямоугольник видимого контента (contain/cover) внутри контейнера. */
|
||||||
onContentRectChange?: ((rect: { x: number; y: number; w: number; h: number }) => void) | undefined;
|
onContentRectChange?: ((rect: { x: number; y: number; w: number; h: number }) => void) | undefined;
|
||||||
};
|
};
|
||||||
@@ -47,12 +54,18 @@ export function RotatedImage({
|
|||||||
loading,
|
loading,
|
||||||
decoding,
|
decoding,
|
||||||
style,
|
style,
|
||||||
|
viewCamera = null,
|
||||||
onContentRectChange,
|
onContentRectChange,
|
||||||
}: RotatedImageProps) {
|
}: RotatedImageProps) {
|
||||||
const [ref, size] = useElementSize<HTMLDivElement>();
|
const [ref, size] = useElementSize<HTMLDivElement>();
|
||||||
const [imgSize, setImgSize] = useState<{ w: number; h: number } | null>(null);
|
const [imgSize, setImgSize] = useState<{ w: number; h: number } | null>(null);
|
||||||
const imgRef = useRef<HTMLImageElement | 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(() => {
|
useLayoutEffect(() => {
|
||||||
// If the image is served from cache, onLoad may fire before listeners attach.
|
// If the image is served from cache, onLoad may fire before listeners attach.
|
||||||
// Reading from the <img> element itself is the most reliable source.
|
// 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 }));
|
setImgSize((prev) => (prev && prev.w === w0 && prev.h === h0 ? prev : { w: w0, h: h0 }));
|
||||||
}, [url]);
|
}, [url]);
|
||||||
|
|
||||||
const scale = useMemo(() => {
|
const fitScale = useMemo(() => {
|
||||||
if (!imgSize) return 1;
|
if (!imgSize) return 1;
|
||||||
if (size.w <= 1 || size.h <= 1) return 1;
|
if (size.w <= 1 || size.h <= 1) return 1;
|
||||||
const rotated = rotationDeg === 90 || rotationDeg === 270;
|
const rotated = rotationDeg === 90 || rotationDeg === 270;
|
||||||
@@ -77,21 +90,28 @@ export function RotatedImage({
|
|||||||
return mode === 'cover' ? Math.max(sx, sy) : Math.min(sx, sy);
|
return mode === 'cover' ? Math.max(sx, sy) : Math.min(sx, sy);
|
||||||
}, [imgSize, mode, rotationDeg, size.h, size.w]);
|
}, [imgSize, mode, rotationDeg, size.h, size.w]);
|
||||||
|
|
||||||
useEffect(() => {
|
const scale = fitScale * viewScale;
|
||||||
if (!onContentRectChange) return;
|
|
||||||
if (!imgSize) return;
|
const contentRect = useMemo(() => {
|
||||||
if (size.w <= 1 || size.h <= 1) return;
|
if (!imgSize) return null;
|
||||||
|
if (size.w <= 1 || size.h <= 1) return null;
|
||||||
const rotated = rotationDeg === 90 || rotationDeg === 270;
|
const rotated = rotationDeg === 90 || rotationDeg === 270;
|
||||||
// Bounding-box размеров после rotate(): при 90/270 меняются местами.
|
|
||||||
const bw = (rotated ? imgSize.h : imgSize.w) * scale;
|
const bw = (rotated ? imgSize.h : imgSize.w) * scale;
|
||||||
const bh = (rotated ? imgSize.w : imgSize.h) * scale;
|
const bh = (rotated ? imgSize.w : imgSize.h) * scale;
|
||||||
const x = (size.w - bw) / 2;
|
const x = size.w / 2 - viewOx * bw;
|
||||||
const y = (size.h - bh) / 2;
|
const y = size.h / 2 - viewOy * bh;
|
||||||
onContentRectChange({ x, y, w: bw, h: bh });
|
return { x, y, w: bw, h: bh };
|
||||||
}, [imgSize, mode, onContentRectChange, rotationDeg, scale, size.h, size.w]);
|
}, [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 w = imgSize ? imgSize.w * scale : undefined;
|
||||||
const h = imgSize ? imgSize.h * 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 (
|
return (
|
||||||
<div ref={ref} className={styles.root} style={style}>
|
<div ref={ref} className={styles.root} style={style}>
|
||||||
@@ -117,6 +137,8 @@ export function RotatedImage({
|
|||||||
style={{
|
style={{
|
||||||
width: w ?? '100%',
|
width: w ?? '100%',
|
||||||
height: h ?? '100%',
|
height: h ?? '100%',
|
||||||
|
left: leftPx !== undefined ? `${String(leftPx)}px` : '50%',
|
||||||
|
top: topPx !== undefined ? `${String(topPx)}px` : '50%',
|
||||||
objectFit: imgSize ? undefined : mode,
|
objectFit: imgSize ? undefined : mode,
|
||||||
transform: `translate(-50%, -50%) rotate(${String(rotationDeg)}deg)`,
|
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'));
|
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 };
|
else viewportRef.current = { x: 0, y: 0, w: sizeRef.current.w, h: sizeRef.current.h };
|
||||||
const pixi = pixiRef.current;
|
const pixi = pixiRef.current;
|
||||||
const root = rootRef.current;
|
const root = rootRef.current;
|
||||||
|
const app = appRef.current;
|
||||||
if (!pixi || !root) return;
|
if (!pixi || !root) return;
|
||||||
syncNodes(pixi, root, nodesRef.current, stateRef.current, sizeRef.current, viewportRef.current);
|
syncNodes(pixi, root, nodesRef.current, stateRef.current, sizeRef.current, viewportRef.current);
|
||||||
|
// При остановленном ticker (или между кадрами) иначе зум/пан не отрисуется.
|
||||||
|
try {
|
||||||
|
app?.render?.();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
}, [viewport]);
|
}, [viewport]);
|
||||||
|
|
||||||
const hostClass = [styles.host, interactive ? styles.hostInteractive : styles.hostPassthrough].join(' ');
|
const hostClass = [styles.host, interactive ? styles.hostInteractive : styles.hostPassthrough].join(' ');
|
||||||
@@ -421,7 +428,8 @@ function syncNodes(
|
|||||||
}
|
}
|
||||||
if (!state) return;
|
if (!state) return;
|
||||||
for (const inst of liveInstances) {
|
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);
|
const existing = nodes.get(inst.id);
|
||||||
if (existing && (existing as any).__sig === sig) continue;
|
if (existing && (existing as any).__sig === sig) continue;
|
||||||
// Water draft: перерисовываем Graphics in-place (без destroy/create на каждую точку).
|
// 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));
|
const halfW = Math.max(1.5, inst.radiusN * Math.min(viewport.w, viewport.h));
|
||||||
redrawWaterDraft((existing as any).__fx.g, inst, viewport, halfW);
|
redrawWaterDraft((existing as any).__fx.g, inst, viewport, halfW);
|
||||||
existing.alpha = Math.max(0.35, Math.min(0.95, inst.opacity * 1.1));
|
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;
|
(existing as any).__sig = sig;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -449,6 +467,7 @@ function syncNodes(
|
|||||||
}
|
}
|
||||||
const node = createInstanceNode(pixi, inst, size, viewport);
|
const node = createInstanceNode(pixi, inst, size, viewport);
|
||||||
if (!node) continue;
|
if (!node) continue;
|
||||||
|
(node as any).__contentSig = contentSig;
|
||||||
(node as any).__sig = sig;
|
(node as any).__sig = sig;
|
||||||
nodes.set(inst.id, node);
|
nodes.set(inst.id, node);
|
||||||
root.addChild(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') {
|
if (inst.type === 'fog') {
|
||||||
const last = inst.points[inst.points.length - 1];
|
const last = inst.points[inst.points.length - 1];
|
||||||
const lx = last ? Math.round(last.x * 1000) : 0;
|
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') {
|
if (inst.type === 'water') {
|
||||||
const hp = hashWaterStroke(inst);
|
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') {
|
if (inst.type === 'lightning') {
|
||||||
return `lt:${Math.round(inst.end.x * 1000)}:${Math.round(inst.end.y * 1000)}:${Math.round(inst.widthN * 1000)}`;
|
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';
|
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 {
|
function hash01(seed: number, n: number): number {
|
||||||
// Дешёвый детерминированный шум 0..1 (без Math.random).
|
// Дешёвый детерминированный шум 0..1 (без Math.random).
|
||||||
let x = (seed ^ (n * 374761393)) >>> 0;
|
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;
|
||||||
|
}
|
||||||
@@ -20,6 +20,8 @@ import type {
|
|||||||
SceneDarknessEvent,
|
SceneDarknessEvent,
|
||||||
SceneDarknessState,
|
SceneDarknessState,
|
||||||
SceneId,
|
SceneId,
|
||||||
|
SceneViewEvent,
|
||||||
|
SceneViewState,
|
||||||
VideoPlaybackEvent,
|
VideoPlaybackEvent,
|
||||||
VideoPlaybackState,
|
VideoPlaybackState,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
@@ -143,6 +145,11 @@ export const ipcChannels = {
|
|||||||
dispatch: 'sceneDarkness.dispatch',
|
dispatch: 'sceneDarkness.dispatch',
|
||||||
stateChanged: 'sceneDarkness.stateChanged',
|
stateChanged: 'sceneDarkness.stateChanged',
|
||||||
},
|
},
|
||||||
|
sceneView: {
|
||||||
|
getState: 'sceneView.getState',
|
||||||
|
dispatch: 'sceneView.dispatch',
|
||||||
|
stateChanged: 'sceneView.stateChanged',
|
||||||
|
},
|
||||||
video: {
|
video: {
|
||||||
getState: 'video.getState',
|
getState: 'video.getState',
|
||||||
dispatch: 'video.dispatch',
|
dispatch: 'video.dispatch',
|
||||||
@@ -203,6 +210,7 @@ export type IpcEventMap = {
|
|||||||
[ipcChannels.materialsOverlay.stateChanged]: { state: MaterialsOverlayState };
|
[ipcChannels.materialsOverlay.stateChanged]: { state: MaterialsOverlayState };
|
||||||
[ipcChannels.npcsOverlay.stateChanged]: { state: NpcsOverlayState };
|
[ipcChannels.npcsOverlay.stateChanged]: { state: NpcsOverlayState };
|
||||||
[ipcChannels.sceneDarkness.stateChanged]: { state: SceneDarknessState };
|
[ipcChannels.sceneDarkness.stateChanged]: { state: SceneDarknessState };
|
||||||
|
[ipcChannels.sceneView.stateChanged]: { state: SceneViewState };
|
||||||
[ipcChannels.video.stateChanged]: { state: VideoPlaybackState };
|
[ipcChannels.video.stateChanged]: { state: VideoPlaybackState };
|
||||||
[ipcChannels.license.statusChanged]: { snapshot: LicenseSnapshot };
|
[ipcChannels.license.statusChanged]: { snapshot: LicenseSnapshot };
|
||||||
[ipcChannels.windows.multiWindowStateChanged]: { open: boolean };
|
[ipcChannels.windows.multiWindowStateChanged]: { open: boolean };
|
||||||
@@ -593,6 +601,14 @@ export type IpcInvokeMap = {
|
|||||||
req: { event: SceneDarknessEvent };
|
req: { event: SceneDarknessEvent };
|
||||||
res: { ok: true };
|
res: { ok: true };
|
||||||
};
|
};
|
||||||
|
[ipcChannels.sceneView.getState]: {
|
||||||
|
req: Record<string, never>;
|
||||||
|
res: { state: SceneViewState };
|
||||||
|
};
|
||||||
|
[ipcChannels.sceneView.dispatch]: {
|
||||||
|
req: { event: SceneViewEvent };
|
||||||
|
res: { ok: true };
|
||||||
|
};
|
||||||
[ipcChannels.video.getState]: {
|
[ipcChannels.video.getState]: {
|
||||||
req: Record<string, never>;
|
req: Record<string, never>;
|
||||||
res: { state: VideoPlaybackState };
|
res: { state: VideoPlaybackState };
|
||||||
@@ -630,6 +646,7 @@ export type LegacyIpcEventMap = {
|
|||||||
[ipcChannels.materialsOverlay.stateChanged]: { state: MaterialsOverlayState };
|
[ipcChannels.materialsOverlay.stateChanged]: { state: MaterialsOverlayState };
|
||||||
[ipcChannels.npcsOverlay.stateChanged]: { state: NpcsOverlayState };
|
[ipcChannels.npcsOverlay.stateChanged]: { state: NpcsOverlayState };
|
||||||
[ipcChannels.sceneDarkness.stateChanged]: { state: SceneDarknessState };
|
[ipcChannels.sceneDarkness.stateChanged]: { state: SceneDarknessState };
|
||||||
|
[ipcChannels.sceneView.stateChanged]: { state: SceneViewState };
|
||||||
[ipcChannels.video.stateChanged]: { state: VideoPlaybackState };
|
[ipcChannels.video.stateChanged]: { state: VideoPlaybackState };
|
||||||
[ipcChannels.license.statusChanged]: Record<string, never>;
|
[ipcChannels.license.statusChanged]: Record<string, never>;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,4 +4,5 @@ export * from './ids';
|
|||||||
export * from './materials';
|
export * from './materials';
|
||||||
export * from './npcs';
|
export * from './npcs';
|
||||||
export * from './sceneDarkness';
|
export * from './sceneDarkness';
|
||||||
|
export * from './sceneView';
|
||||||
export * from './videoPlayback';
|
export * from './videoPlayback';
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user