feat(scene): shared zoom/pan for control and presentation

Keep effects pinned to map coordinates when the viewport changes; materials/NPC overlays stay screen-fixed.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-07-23 14:08:19 +08:00
parent 2c0e6fbf09
commit eb127c11c2
13 changed files with 688 additions and 19 deletions
+1
View File
@@ -4,4 +4,5 @@ export * from './ids';
export * from './materials';
export * from './npcs';
export * from './sceneDarkness';
export * from './sceneView';
export * from './videoPlayback';
+94
View File
@@ -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,
});
}