feat: 添加跨平台运行时、资产系统和UI适配功能 (#256)
* 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检测到的代码问题
This commit is contained in:
390
packages/editor-app/src/components/EditorViewport.tsx
Normal file
390
packages/editor-app/src/components/EditorViewport.tsx
Normal file
@@ -0,0 +1,390 @@
|
||||
/**
|
||||
* 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;
|
||||
Reference in New Issue
Block a user