* feat(platform-common): 添加WASM加载器和环境检测API * feat(rapier2d): 新增Rapier2D WASM绑定包 * feat(physics-rapier2d): 添加跨平台WASM加载器 * feat(asset-system): 添加运行时资产目录和bundle格式 * feat(asset-system-editor): 新增编辑器资产管理包 * feat(editor-core): 添加构建系统和模块管理 * feat(editor-app): 重构浏览器预览使用import maps * feat(platform-web): 添加BrowserRuntime和资产读取 * feat(engine): 添加材质系统和着色器管理 * feat(material): 新增材质系统和着色器编辑器 * feat(tilemap): 增强tilemap编辑器和动画系统 * feat(modules): 添加module.json配置 * feat(core): 添加module.json和类型定义更新 * chore: 更新依赖和构建配置 * refactor(plugins): 更新插件模板使用ModuleManifest * chore: 添加第三方依赖库 * chore: 移除BehaviourTree-ai和ecs-astar子模块 * docs: 更新README和文档主题样式 * fix: 修复Rust文档测试和添加rapier2d WASM绑定 * fix(tilemap-editor): 修复画布高DPI屏幕分辨率适配问题 * feat(ui): 添加UI屏幕适配系统(CanvasScaler/SafeArea) * fix(ecs-engine-bindgen): 添加缺失的ecs-framework-math依赖 * fix: 添加缺失的包依赖修复CI构建 * fix: 修复CodeQL检测到的代码问题 * fix: 修复构建错误和缺失依赖 * fix: 修复类型检查错误 * fix(material-system): 修复tsconfig配置支持TypeScript项目引用 * fix(editor-core): 修复Rollup构建配置添加tauri external * fix: 修复CodeQL检测到的代码问题 * fix: 修复CodeQL检测到的代码问题
391 lines
13 KiB
TypeScript
391 lines
13 KiB
TypeScript
/**
|
|
* EditorViewport Component
|
|
* 编辑器视口组件
|
|
*
|
|
* A reusable viewport component for editor panels that need engine rendering.
|
|
* Supports camera controls, overlays, and preview scenes.
|
|
*
|
|
* 用于需要引擎渲染的编辑器面板的可重用视口组件。
|
|
* 支持相机控制、覆盖层和预览场景。
|
|
*/
|
|
|
|
import { useEffect, useRef, useCallback, useState, forwardRef, useImperativeHandle } from 'react';
|
|
import type { ViewportCameraConfig, IViewportOverlay } from '@esengine/editor-core';
|
|
import { ViewportService } from '../services/ViewportService';
|
|
import '../styles/EditorViewport.css';
|
|
|
|
/**
|
|
* EditorViewport configuration
|
|
* 编辑器视口配置
|
|
*/
|
|
export interface EditorViewportConfig {
|
|
/** Unique viewport identifier | 唯一视口标识符 */
|
|
viewportId: string;
|
|
/** Initial camera config | 初始相机配置 */
|
|
initialCamera?: ViewportCameraConfig;
|
|
/** Whether to show grid | 是否显示网格 */
|
|
showGrid?: boolean;
|
|
/** Whether to show gizmos | 是否显示辅助线 */
|
|
showGizmos?: boolean;
|
|
/** Background clear color | 背景清除颜色 */
|
|
clearColor?: { r: number; g: number; b: number; a: number };
|
|
/** Min zoom level | 最小缩放级别 */
|
|
minZoom?: number;
|
|
/** Max zoom level | 最大缩放级别 */
|
|
maxZoom?: number;
|
|
/** Enable camera pan | 启用相机平移 */
|
|
enablePan?: boolean;
|
|
/** Enable camera zoom | 启用相机缩放 */
|
|
enableZoom?: boolean;
|
|
}
|
|
|
|
/**
|
|
* EditorViewport props
|
|
* 编辑器视口属性
|
|
*/
|
|
export interface EditorViewportProps extends EditorViewportConfig {
|
|
/** Class name for styling | 样式类名 */
|
|
className?: string;
|
|
/** Called when camera changes | 相机变化时的回调 */
|
|
onCameraChange?: (camera: ViewportCameraConfig) => void;
|
|
/** Called when viewport is ready | 视口准备就绪时的回调 */
|
|
onReady?: () => void;
|
|
/** Called on mouse down | 鼠标按下时的回调 */
|
|
onMouseDown?: (e: React.MouseEvent, worldPos: { x: number; y: number }) => void;
|
|
/** Called on mouse move | 鼠标移动时的回调 */
|
|
onMouseMove?: (e: React.MouseEvent, worldPos: { x: number; y: number }) => void;
|
|
/** Called on mouse up | 鼠标抬起时的回调 */
|
|
onMouseUp?: (e: React.MouseEvent, worldPos: { x: number; y: number }) => void;
|
|
/** Called on mouse wheel | 鼠标滚轮时的回调 */
|
|
onWheel?: (e: React.WheelEvent, worldPos: { x: number; y: number }) => void;
|
|
/** Render custom overlays | 渲染自定义覆盖层 */
|
|
renderOverlays?: () => React.ReactNode;
|
|
}
|
|
|
|
/**
|
|
* EditorViewport handle for imperative access
|
|
* 编辑器视口句柄,用于命令式访问
|
|
*/
|
|
export interface EditorViewportHandle {
|
|
/** Get current camera | 获取当前相机 */
|
|
getCamera(): ViewportCameraConfig;
|
|
/** Set camera | 设置相机 */
|
|
setCamera(camera: ViewportCameraConfig): void;
|
|
/** Reset camera to initial state | 重置相机到初始状态 */
|
|
resetCamera(): void;
|
|
/** Convert screen coordinates to world coordinates | 将屏幕坐标转换为世界坐标 */
|
|
screenToWorld(screenX: number, screenY: number): { x: number; y: number };
|
|
/** Convert world coordinates to screen coordinates | 将世界坐标转换为屏幕坐标 */
|
|
worldToScreen(worldX: number, worldY: number): { x: number; y: number };
|
|
/** Get canvas element | 获取画布元素 */
|
|
getCanvas(): HTMLCanvasElement | null;
|
|
/** Request render | 请求渲染 */
|
|
requestRender(): void;
|
|
}
|
|
|
|
/**
|
|
* EditorViewport Component
|
|
* 编辑器视口组件
|
|
*/
|
|
export const EditorViewport = forwardRef<EditorViewportHandle, EditorViewportProps>(function EditorViewport(
|
|
{
|
|
viewportId,
|
|
initialCamera = { x: 0, y: 0, zoom: 1 },
|
|
showGrid = true,
|
|
showGizmos = false,
|
|
clearColor,
|
|
minZoom = 0.1,
|
|
maxZoom = 10,
|
|
enablePan = true,
|
|
enableZoom = true,
|
|
className,
|
|
onCameraChange,
|
|
onReady,
|
|
onMouseDown,
|
|
onMouseMove,
|
|
onMouseUp,
|
|
onWheel,
|
|
renderOverlays
|
|
},
|
|
ref
|
|
) {
|
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const [isReady, setIsReady] = useState(false);
|
|
|
|
// Camera state
|
|
const [camera, setCamera] = useState<ViewportCameraConfig>(initialCamera);
|
|
const cameraRef = useRef(camera);
|
|
|
|
// Drag state
|
|
const isDraggingRef = useRef(false);
|
|
const lastMousePosRef = useRef({ x: 0, y: 0 });
|
|
|
|
// Keep camera ref in sync
|
|
useEffect(() => {
|
|
cameraRef.current = camera;
|
|
}, [camera]);
|
|
|
|
// Screen to world conversion
|
|
const screenToWorld = useCallback((screenX: number, screenY: number): { x: number; y: number } => {
|
|
const canvas = canvasRef.current;
|
|
if (!canvas) return { x: 0, y: 0 };
|
|
|
|
const rect = canvas.getBoundingClientRect();
|
|
const dpr = window.devicePixelRatio || 1;
|
|
|
|
// Convert to canvas pixel coordinates
|
|
const canvasX = (screenX - rect.left) * dpr;
|
|
const canvasY = (screenY - rect.top) * dpr;
|
|
|
|
// Convert to centered coordinates (Y-up)
|
|
const centeredX = canvasX - canvas.width / 2;
|
|
const centeredY = canvas.height / 2 - canvasY;
|
|
|
|
// Apply inverse zoom and add camera position
|
|
const cam = cameraRef.current;
|
|
const worldX = centeredX / cam.zoom + cam.x;
|
|
const worldY = centeredY / cam.zoom + cam.y;
|
|
|
|
return { x: worldX, y: worldY };
|
|
}, []);
|
|
|
|
// World to screen conversion
|
|
const worldToScreen = useCallback((worldX: number, worldY: number): { x: number; y: number } => {
|
|
const canvas = canvasRef.current;
|
|
if (!canvas) return { x: 0, y: 0 };
|
|
|
|
const rect = canvas.getBoundingClientRect();
|
|
const dpr = window.devicePixelRatio || 1;
|
|
const cam = cameraRef.current;
|
|
|
|
// Apply camera transform
|
|
const centeredX = (worldX - cam.x) * cam.zoom;
|
|
const centeredY = (worldY - cam.y) * cam.zoom;
|
|
|
|
// Convert from centered coordinates
|
|
const canvasX = centeredX + canvas.width / 2;
|
|
const canvasY = canvas.height / 2 - centeredY;
|
|
|
|
// Convert to screen coordinates
|
|
const screenX = canvasX / dpr + rect.left;
|
|
const screenY = canvasY / dpr + rect.top;
|
|
|
|
return { x: screenX, y: screenY };
|
|
}, []);
|
|
|
|
// Request render
|
|
const requestRender = useCallback(() => {
|
|
const viewportService = ViewportService.getInstance();
|
|
if (viewportService.isInitialized()) {
|
|
viewportService.renderToViewport(viewportId);
|
|
}
|
|
}, [viewportId]);
|
|
|
|
// Expose imperative handle
|
|
useImperativeHandle(ref, () => ({
|
|
getCamera: () => cameraRef.current,
|
|
setCamera: (newCamera: ViewportCameraConfig) => {
|
|
setCamera(newCamera);
|
|
onCameraChange?.(newCamera);
|
|
},
|
|
resetCamera: () => {
|
|
setCamera(initialCamera);
|
|
onCameraChange?.(initialCamera);
|
|
},
|
|
screenToWorld,
|
|
worldToScreen,
|
|
getCanvas: () => canvasRef.current,
|
|
requestRender
|
|
}), [initialCamera, screenToWorld, worldToScreen, onCameraChange, requestRender]);
|
|
|
|
// Initialize viewport
|
|
useEffect(() => {
|
|
const canvas = canvasRef.current;
|
|
const container = containerRef.current;
|
|
if (!canvas || !container) return;
|
|
|
|
const canvasId = `editor-viewport-canvas-${viewportId}`;
|
|
canvas.id = canvasId;
|
|
|
|
const viewportService = ViewportService.getInstance();
|
|
|
|
// Wait for service to be initialized
|
|
const checkInit = () => {
|
|
if (viewportService.isInitialized()) {
|
|
// Register viewport
|
|
viewportService.registerViewport(viewportId, canvasId);
|
|
viewportService.setViewportConfig(viewportId, showGrid, showGizmos);
|
|
viewportService.setViewportCamera(viewportId, camera);
|
|
|
|
setIsReady(true);
|
|
onReady?.();
|
|
} else {
|
|
// Retry after a short delay
|
|
setTimeout(checkInit, 100);
|
|
}
|
|
};
|
|
|
|
checkInit();
|
|
|
|
return () => {
|
|
if (viewportService.isInitialized()) {
|
|
viewportService.unregisterViewport(viewportId);
|
|
}
|
|
};
|
|
}, [viewportId]);
|
|
|
|
// Update viewport config when props change
|
|
useEffect(() => {
|
|
if (!isReady) return;
|
|
|
|
const viewportService = ViewportService.getInstance();
|
|
if (viewportService.isInitialized()) {
|
|
viewportService.setViewportConfig(viewportId, showGrid, showGizmos);
|
|
}
|
|
}, [viewportId, showGrid, showGizmos, isReady]);
|
|
|
|
// Sync camera to viewport service
|
|
useEffect(() => {
|
|
if (!isReady) return;
|
|
|
|
const viewportService = ViewportService.getInstance();
|
|
if (viewportService.isInitialized()) {
|
|
viewportService.setViewportCamera(viewportId, camera);
|
|
}
|
|
}, [viewportId, camera, isReady]);
|
|
|
|
// Handle resize
|
|
useEffect(() => {
|
|
const canvas = canvasRef.current;
|
|
const container = containerRef.current;
|
|
if (!canvas || !container) return;
|
|
|
|
const resizeCanvas = () => {
|
|
const rect = container.getBoundingClientRect();
|
|
const dpr = window.devicePixelRatio || 1;
|
|
|
|
canvas.width = rect.width * dpr;
|
|
canvas.height = rect.height * dpr;
|
|
canvas.style.width = `${rect.width}px`;
|
|
canvas.style.height = `${rect.height}px`;
|
|
|
|
if (isReady) {
|
|
const viewportService = ViewportService.getInstance();
|
|
if (viewportService.isInitialized()) {
|
|
viewportService.resizeViewport(viewportId, canvas.width, canvas.height);
|
|
}
|
|
}
|
|
};
|
|
|
|
resizeCanvas();
|
|
|
|
let rafId: number | null = null;
|
|
const resizeObserver = new ResizeObserver(() => {
|
|
if (rafId !== null) {
|
|
cancelAnimationFrame(rafId);
|
|
}
|
|
rafId = requestAnimationFrame(() => {
|
|
resizeCanvas();
|
|
rafId = null;
|
|
});
|
|
});
|
|
|
|
resizeObserver.observe(container);
|
|
|
|
return () => {
|
|
if (rafId !== null) {
|
|
cancelAnimationFrame(rafId);
|
|
}
|
|
resizeObserver.disconnect();
|
|
};
|
|
}, [viewportId, isReady]);
|
|
|
|
// Mouse handlers
|
|
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
|
const worldPos = screenToWorld(e.clientX, e.clientY);
|
|
|
|
// Middle or right button for camera pan
|
|
if (enablePan && (e.button === 1 || e.button === 2)) {
|
|
isDraggingRef.current = true;
|
|
lastMousePosRef.current = { x: e.clientX, y: e.clientY };
|
|
e.preventDefault();
|
|
}
|
|
|
|
onMouseDown?.(e, worldPos);
|
|
}, [enablePan, screenToWorld, onMouseDown]);
|
|
|
|
const handleMouseMove = useCallback((e: React.MouseEvent) => {
|
|
const worldPos = screenToWorld(e.clientX, e.clientY);
|
|
|
|
if (isDraggingRef.current && enablePan) {
|
|
const deltaX = e.clientX - lastMousePosRef.current.x;
|
|
const deltaY = e.clientY - lastMousePosRef.current.y;
|
|
const dpr = window.devicePixelRatio || 1;
|
|
|
|
setCamera(prev => {
|
|
const newCamera = {
|
|
...prev,
|
|
x: prev.x - (deltaX * dpr) / prev.zoom,
|
|
y: prev.y + (deltaY * dpr) / prev.zoom
|
|
};
|
|
onCameraChange?.(newCamera);
|
|
return newCamera;
|
|
});
|
|
|
|
lastMousePosRef.current = { x: e.clientX, y: e.clientY };
|
|
}
|
|
|
|
onMouseMove?.(e, worldPos);
|
|
}, [enablePan, screenToWorld, onMouseMove, onCameraChange]);
|
|
|
|
const handleMouseUp = useCallback((e: React.MouseEvent) => {
|
|
const worldPos = screenToWorld(e.clientX, e.clientY);
|
|
isDraggingRef.current = false;
|
|
onMouseUp?.(e, worldPos);
|
|
}, [screenToWorld, onMouseUp]);
|
|
|
|
const handleWheel = useCallback((e: React.WheelEvent) => {
|
|
const worldPos = screenToWorld(e.clientX, e.clientY);
|
|
|
|
if (enableZoom) {
|
|
e.preventDefault();
|
|
const zoomFactor = e.deltaY > 0 ? 0.9 : 1.1;
|
|
|
|
setCamera(prev => {
|
|
const newZoom = Math.max(minZoom, Math.min(maxZoom, prev.zoom * zoomFactor));
|
|
const newCamera = { ...prev, zoom: newZoom };
|
|
onCameraChange?.(newCamera);
|
|
return newCamera;
|
|
});
|
|
}
|
|
|
|
onWheel?.(e, worldPos);
|
|
}, [enableZoom, minZoom, maxZoom, screenToWorld, onWheel, onCameraChange]);
|
|
|
|
const handleContextMenu = useCallback((e: React.MouseEvent) => {
|
|
e.preventDefault();
|
|
}, []);
|
|
|
|
return (
|
|
<div
|
|
ref={containerRef}
|
|
className={`editor-viewport ${className || ''}`}
|
|
>
|
|
<canvas
|
|
ref={canvasRef}
|
|
className="editor-viewport-canvas"
|
|
onMouseDown={handleMouseDown}
|
|
onMouseMove={handleMouseMove}
|
|
onMouseUp={handleMouseUp}
|
|
onMouseLeave={handleMouseUp}
|
|
onWheel={handleWheel}
|
|
onContextMenu={handleContextMenu}
|
|
/>
|
|
{renderOverlays?.()}
|
|
</div>
|
|
);
|
|
});
|
|
|
|
export default EditorViewport;
|