import { useEffect, useRef, useState, useCallback } from 'react'; import { RotateCcw, Maximize2, Grid3x3, Eye, EyeOff, Activity, MousePointer2, Move, RotateCw, Scaling, Globe, QrCode, ChevronDown, Magnet, ZoomIn } from 'lucide-react'; import '../styles/Viewport.css'; import { useEngine } from '../hooks/useEngine'; import { EngineService } from '../services/EngineService'; import { Core, Entity, SceneSerializer } from '@esengine/ecs-framework'; import { MessageHub, ProjectService, AssetRegistryService } from '@esengine/editor-core'; import { TransformComponent } from '@esengine/engine-core'; import { CameraComponent } from '@esengine/camera'; import { UITransformComponent } from '@esengine/ui'; import { TauriAPI } from '../api/tauri'; import { open } from '@tauri-apps/plugin-shell'; import { RuntimeResolver } from '../services/RuntimeResolver'; import { QRCodeDialog } from './QRCodeDialog'; // Generate runtime HTML for browser preview function generateRuntimeHtml(): string { return ` ECS Runtime Preview `; } // Transform tool modes export type TransformMode = 'select' | 'move' | 'rotate' | 'scale'; export type PlayState = 'stopped' | 'playing' | 'paused'; interface ViewportProps { locale?: string; messageHub?: MessageHub; } export function Viewport({ locale = 'en', messageHub }: ViewportProps) { const canvasRef = useRef(null); const containerRef = useRef(null); const [playState, setPlayState] = useState('stopped'); const [showGrid, setShowGrid] = useState(true); const [showGizmos, setShowGizmos] = useState(true); const [showStats, setShowStats] = useState(false); const [transformMode, setTransformMode] = useState('select'); const [showRunMenu, setShowRunMenu] = useState(false); const [showQRDialog, setShowQRDialog] = useState(false); const [devicePreviewUrl, setDevicePreviewUrl] = useState(''); const runMenuRef = useRef(null); // Snap settings const [snapEnabled, setSnapEnabled] = useState(true); const [gridSnapValue, setGridSnapValue] = useState(10); const [rotationSnapValue, setRotationSnapValue] = useState(15); const [scaleSnapValue, setScaleSnapValue] = useState(0.25); const [showGridSnapMenu, setShowGridSnapMenu] = useState(false); const [showRotationSnapMenu, setShowRotationSnapMenu] = useState(false); const [showScaleSnapMenu, setShowScaleSnapMenu] = useState(false); const gridSnapMenuRef = useRef(null); const rotationSnapMenuRef = useRef(null); const scaleSnapMenuRef = useRef(null); // Store editor camera state when entering play mode const editorCameraRef = useRef({ x: 0, y: 0, zoom: 1 }); const playStateRef = useRef('stopped'); // Keep ref in sync with state useEffect(() => { playStateRef.current = playState; }, [playState]); // Rust engine hook with multi-viewport support const engine = useEngine({ viewportId: 'editor-viewport', canvasId: 'viewport-canvas', showGrid: true, showGizmos: true, autoInit: true }); // Camera state const [camera2DOffset, setCamera2DOffset] = useState({ x: 0, y: 0 }); const [camera2DZoom, setCamera2DZoom] = useState(1); const camera2DZoomRef = useRef(1); const camera2DOffsetRef = useRef({ x: 0, y: 0 }); const isDraggingCameraRef = useRef(false); const isDraggingTransformRef = useRef(false); const lastMousePosRef = useRef({ x: 0, y: 0 }); const selectedEntityRef = useRef(null); const messageHubRef = useRef(null); const transformModeRef = useRef('select'); const snapEnabledRef = useRef(true); const gridSnapRef = useRef(10); const rotationSnapRef = useRef(15); const scaleSnapRef = useRef(0.25); // Keep refs in sync with state useEffect(() => { camera2DZoomRef.current = camera2DZoom; }, [camera2DZoom]); useEffect(() => { camera2DOffsetRef.current = camera2DOffset; }, [camera2DOffset]); useEffect(() => { transformModeRef.current = transformMode; }, [transformMode]); useEffect(() => { snapEnabledRef.current = snapEnabled; }, [snapEnabled]); useEffect(() => { gridSnapRef.current = gridSnapValue; }, [gridSnapValue]); useEffect(() => { rotationSnapRef.current = rotationSnapValue; }, [rotationSnapValue]); useEffect(() => { scaleSnapRef.current = scaleSnapValue; }, [scaleSnapValue]); // Snap helper functions const snapToGrid = useCallback((value: number): number => { if (!snapEnabledRef.current || gridSnapRef.current <= 0) return value; return Math.round(value / gridSnapRef.current) * gridSnapRef.current; }, []); const snapRotation = useCallback((value: number): number => { if (!snapEnabledRef.current || rotationSnapRef.current <= 0) return value; const degrees = (value * 180) / Math.PI; const snappedDegrees = Math.round(degrees / rotationSnapRef.current) * rotationSnapRef.current; return (snappedDegrees * Math.PI) / 180; }, []); const snapScale = useCallback((value: number): number => { if (!snapEnabledRef.current || scaleSnapRef.current <= 0) return value; return Math.round(value / scaleSnapRef.current) * scaleSnapRef.current; }, []); // Screen to world coordinate conversion - uses refs to avoid re-registering event handlers const screenToWorld = useCallback((screenX: number, screenY: 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 - use refs for current values const zoom = camera2DZoomRef.current; const offset = camera2DOffsetRef.current; const worldX = centeredX / zoom + offset.x; const worldY = centeredY / zoom + offset.y; return { x: worldX, y: worldY }; }, []); // Subscribe to entity selection events useEffect(() => { const hub = Core.services.tryResolve(MessageHub); if (hub) { messageHubRef.current = hub; const unsub = hub.subscribe('entity:selected', (data: { entity: Entity | null }) => { selectedEntityRef.current = data.entity; }); return () => unsub(); } }, []); // Canvas setup and input handling useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; canvas.style.cursor = 'grab'; const resizeCanvas = () => { if (!canvas || !containerRef.current) return; const container = containerRef.current; 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`; EngineService.getInstance().resize(canvas.width, canvas.height); }; resizeCanvas(); window.addEventListener('resize', resizeCanvas); let rafId: number | null = null; const resizeObserver = new ResizeObserver(() => { // 使用 requestAnimationFrame 避免 ResizeObserver loop 错误 // Use requestAnimationFrame to avoid ResizeObserver loop errors if (rafId !== null) { cancelAnimationFrame(rafId); } rafId = requestAnimationFrame(() => { resizeCanvas(); rafId = null; }); }); if (containerRef.current) { resizeObserver.observe(containerRef.current); } const handleMouseDown = (e: MouseEvent) => { // Disable camera/transform manipulation in play mode if (playStateRef.current === 'playing') { return; } // Middle mouse button (1) or right button (2) for camera pan if (e.button === 1 || e.button === 2) { isDraggingCameraRef.current = true; lastMousePosRef.current = { x: e.clientX, y: e.clientY }; canvas.style.cursor = 'grabbing'; e.preventDefault(); } // Left button (0) for transform or camera pan (if no transform mode active) else if (e.button === 0) { if (transformModeRef.current === 'select') { // In select mode, left click pans camera isDraggingCameraRef.current = true; canvas.style.cursor = 'grabbing'; } else { // In transform mode, left click transforms entity isDraggingTransformRef.current = true; canvas.style.cursor = 'move'; } lastMousePosRef.current = { x: e.clientX, y: e.clientY }; e.preventDefault(); } }; const handleMouseMove = (e: MouseEvent) => { const deltaX = e.clientX - lastMousePosRef.current.x; const deltaY = e.clientY - lastMousePosRef.current.y; if (isDraggingCameraRef.current) { // Camera pan - use ref to avoid stale closure const dpr = window.devicePixelRatio || 1; const zoom = camera2DZoomRef.current; setCamera2DOffset((prev) => ({ x: prev.x - (deltaX * dpr) / zoom, y: prev.y + (deltaY * dpr) / zoom })); } else if (isDraggingTransformRef.current) { // Transform selected entity based on mode const entity = selectedEntityRef.current; if (!entity) return; const worldStart = screenToWorld(lastMousePosRef.current.x, lastMousePosRef.current.y); const worldEnd = screenToWorld(e.clientX, e.clientY); const worldDelta = { x: worldEnd.x - worldStart.x, y: worldEnd.y - worldStart.y }; const mode = transformModeRef.current; // Try standard TransformComponent first const transform = entity.getComponent(TransformComponent); if (transform) { if (mode === 'move') { transform.position.x += worldDelta.x; transform.position.y += worldDelta.y; } else if (mode === 'rotate') { const rotationSpeed = 0.01; transform.rotation.z += deltaX * rotationSpeed; } else if (mode === 'scale') { const centerX = transform.position.x; const centerY = transform.position.y; const startDist = Math.sqrt((worldStart.x - centerX) ** 2 + (worldStart.y - centerY) ** 2); const endDist = Math.sqrt((worldEnd.x - centerX) ** 2 + (worldEnd.y - centerY) ** 2); if (startDist > 0) { const scaleFactor = endDist / startDist; transform.scale.x *= scaleFactor; transform.scale.y *= scaleFactor; } } if (messageHubRef.current) { const propertyName = mode === 'move' ? 'position' : mode === 'rotate' ? 'rotation' : 'scale'; const value = propertyName === 'position' ? transform.position : propertyName === 'rotation' ? transform.rotation : transform.scale; messageHubRef.current.publish('component:property:changed', { entity, component: transform, propertyName, value }); } } // Try UITransformComponent const uiTransform = entity.getComponent(UITransformComponent); if (uiTransform) { if (mode === 'move') { uiTransform.x += worldDelta.x; uiTransform.y += worldDelta.y; } else if (mode === 'rotate') { const rotationSpeed = 0.01; uiTransform.rotation += deltaX * rotationSpeed; } else if (mode === 'scale') { const oldWidth = uiTransform.width * uiTransform.scaleX; const oldHeight = uiTransform.height * uiTransform.scaleY; // pivot点的世界坐标(缩放前) const pivotWorldX = uiTransform.x + oldWidth * uiTransform.pivotX; const pivotWorldY = uiTransform.y + oldHeight * uiTransform.pivotY; const startDist = Math.sqrt((worldStart.x - pivotWorldX) ** 2 + (worldStart.y - pivotWorldY) ** 2); const endDist = Math.sqrt((worldEnd.x - pivotWorldX) ** 2 + (worldEnd.y - pivotWorldY) ** 2); if (startDist > 0) { const scaleFactor = endDist / startDist; const newScaleX = uiTransform.scaleX * scaleFactor; const newScaleY = uiTransform.scaleY * scaleFactor; const newWidth = uiTransform.width * newScaleX; const newHeight = uiTransform.height * newScaleY; // 调整位置使pivot点保持不动 uiTransform.x = pivotWorldX - newWidth * uiTransform.pivotX; uiTransform.y = pivotWorldY - newHeight * uiTransform.pivotY; uiTransform.scaleX = newScaleX; uiTransform.scaleY = newScaleY; } } if (messageHubRef.current) { const propertyName = mode === 'move' ? 'x' : mode === 'rotate' ? 'rotation' : 'scaleX'; messageHubRef.current.publish('component:property:changed', { entity, component: uiTransform, propertyName, value: uiTransform[propertyName] }); } } } else { return; } lastMousePosRef.current = { x: e.clientX, y: e.clientY }; }; const handleMouseUp = () => { if (isDraggingCameraRef.current) { isDraggingCameraRef.current = false; canvas.style.cursor = 'grab'; } if (isDraggingTransformRef.current) { isDraggingTransformRef.current = false; canvas.style.cursor = 'grab'; // Apply snap on mouse up const entity = selectedEntityRef.current; if (entity && snapEnabledRef.current) { const mode = transformModeRef.current; const transform = entity.getComponent(TransformComponent); if (transform) { if (mode === 'move') { transform.position.x = snapToGrid(transform.position.x); transform.position.y = snapToGrid(transform.position.y); } else if (mode === 'rotate') { transform.rotation.z = snapRotation(transform.rotation.z); } else if (mode === 'scale') { transform.scale.x = snapScale(transform.scale.x); transform.scale.y = snapScale(transform.scale.y); } } const uiTransform = entity.getComponent(UITransformComponent); if (uiTransform) { if (mode === 'move') { uiTransform.x = snapToGrid(uiTransform.x); uiTransform.y = snapToGrid(uiTransform.y); } else if (mode === 'rotate') { uiTransform.rotation = snapRotation(uiTransform.rotation); } else if (mode === 'scale') { uiTransform.scaleX = snapScale(uiTransform.scaleX); uiTransform.scaleY = snapScale(uiTransform.scaleY); } } } // Notify Inspector to refresh after transform change if (messageHubRef.current && selectedEntityRef.current) { messageHubRef.current.publish('entity:selected', { entity: selectedEntityRef.current }); } } }; // Prevent context menu on right click const handleContextMenu = (e: MouseEvent) => { e.preventDefault(); }; const handleWheel = (e: WheelEvent) => { e.preventDefault(); // Disable zoom in play mode if (playStateRef.current === 'playing') { return; } // Use multiplicative zoom for consistent feel across all zoom levels // 使用乘法缩放,在所有缩放级别都有一致的感觉 const zoomFactor = e.deltaY > 0 ? 0.9 : 1.1; setCamera2DZoom((prev) => Math.max(0.01, Math.min(100, prev * zoomFactor))); }; canvas.addEventListener('mousedown', handleMouseDown); canvas.addEventListener('wheel', handleWheel, { passive: false }); canvas.addEventListener('contextmenu', handleContextMenu); document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mouseup', handleMouseUp); return () => { if (rafId !== null) { cancelAnimationFrame(rafId); } window.removeEventListener('resize', resizeCanvas); resizeObserver.disconnect(); canvas.removeEventListener('mousedown', handleMouseDown); canvas.removeEventListener('wheel', handleWheel); canvas.removeEventListener('contextmenu', handleContextMenu); document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleMouseUp); }; }, []); // Sync camera state to engine and publish camera:updated event // 同步相机状态到引擎并发布 camera:updated 事件 useEffect(() => { if (engine.state.initialized) { EngineService.getInstance().setCamera({ x: camera2DOffset.x, y: camera2DOffset.y, zoom: camera2DZoom, rotation: 0 }); // Publish camera update event for other systems // 发布相机更新事件供其他系统使用 const hub = messageHubRef.current; if (hub) { hub.publish('camera:updated', { x: camera2DOffset.x, y: camera2DOffset.y, zoom: camera2DZoom }); } } }, [camera2DOffset, camera2DZoom, engine.state.initialized]); // Sync grid and gizmo visibility useEffect(() => { if (engine.state.initialized) { EngineService.getInstance().setShowGrid(showGrid); EngineService.getInstance().setShowGizmos(showGizmos); } }, [showGrid, showGizmos, engine.state.initialized]); // Sync transform mode to engine useEffect(() => { if (engine.state.initialized) { EngineService.getInstance().setTransformMode(transformMode); } }, [transformMode, engine.state.initialized]); // Find player camera in scene const findPlayerCamera = useCallback((): Entity | null => { const scene = Core.scene; if (!scene) return null; const cameraEntities = scene.entities.findEntitiesWithComponent(CameraComponent); return cameraEntities.length > 0 ? cameraEntities[0]! : null; }, []); // Sync player camera to viewport when playing const syncPlayerCamera = useCallback(() => { const cameraEntity = findPlayerCamera(); if (!cameraEntity) return; const transform = cameraEntity.getComponent(TransformComponent); const camera = cameraEntity.getComponent(CameraComponent); if (transform && camera) { const zoom = camera.orthographicSize > 0 ? 1 / camera.orthographicSize : 1; setCamera2DOffset({ x: transform.position.x, y: transform.position.y }); setCamera2DZoom(zoom); // Set background color from camera const bgColor = camera.backgroundColor || '#000000'; const r = parseInt(bgColor.slice(1, 3), 16) / 255; const g = parseInt(bgColor.slice(3, 5), 16) / 255; const b = parseInt(bgColor.slice(5, 7), 16) / 255; EngineService.getInstance().setClearColor(r, g, b, 1.0); } }, [findPlayerCamera]); // Close run menu when clicking outside useEffect(() => { const handleClickOutside = (e: MouseEvent) => { if (runMenuRef.current && !runMenuRef.current.contains(e.target as Node)) { setShowRunMenu(false); } }; document.addEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside); }, []); const handlePlay = () => { if (playState === 'stopped') { // Check if there's a camera entity const cameraEntity = findPlayerCamera(); if (!cameraEntity) { const warningMessage = locale === 'zh' ? '缺少相机: 场景中没有相机实体,请添加一个带有Camera组件的实体' : 'Missing Camera: No camera entity in scene. Please add an entity with Camera component.'; if (messageHub) { messageHub.publish('notification:show', { message: warningMessage, type: 'warning', timestamp: Date.now() }); } else { console.warn(warningMessage); } return; } // Save scene snapshot before playing EngineService.getInstance().saveSceneSnapshot(); // Save editor camera state editorCameraRef.current = { x: camera2DOffset.x, y: camera2DOffset.y, zoom: camera2DZoom }; setPlayState('playing'); // Hide grid and gizmos in play mode EngineService.getInstance().setShowGrid(false); EngineService.getInstance().setShowGizmos(false); // Switch to player camera syncPlayerCamera(); engine.start(); } else if (playState === 'paused') { setPlayState('playing'); engine.start(); } }; const handlePause = () => { if (playState === 'playing') { setPlayState('paused'); engine.stop(); } }; const handleStop = async () => { setPlayState('stopped'); engine.stop(); // Restore scene snapshot await EngineService.getInstance().restoreSceneSnapshot(); // Restore editor camera state setCamera2DOffset({ x: editorCameraRef.current.x, y: editorCameraRef.current.y }); setCamera2DZoom(editorCameraRef.current.zoom); // Restore grid and gizmos EngineService.getInstance().setShowGrid(showGrid); EngineService.getInstance().setShowGizmos(showGizmos); // Restore editor default background color EngineService.getInstance().setClearColor(0.1, 0.1, 0.12, 1.0); }; const handleReset = () => { // Reset camera to origin without stopping playback setCamera2DOffset({ x: 0, y: 0 }); setCamera2DZoom(1); }; // Store handlers in refs to avoid dependency issues const handlePlayRef = useRef(handlePlay); const handlePauseRef = useRef(handlePause); const handleStopRef = useRef(handleStop); const handleRunInBrowserRef = useRef<(() => void) | null>(null); const handleRunOnDeviceRef = useRef<(() => void) | null>(null); handlePlayRef.current = handlePlay; handlePauseRef.current = handlePause; handleStopRef.current = handleStop; const handleRunInBrowser = async () => { setShowRunMenu(false); try { const engineService = EngineService.getInstance(); const scene = engineService.getScene(); if (!scene) { messageHub?.publish('notification:error', { title: locale === 'zh' ? '错误' : 'Error', message: locale === 'zh' ? '没有可运行的场景' : 'No scene to run' }); return; } // Serialize current scene const serialized = SceneSerializer.serialize(scene, { format: 'json', pretty: true, includeMetadata: true }); // Ensure we have string data const sceneData = typeof serialized === 'string' ? serialized : new TextDecoder().decode(serialized); // Get temp directory and create runtime files const tempDir = await TauriAPI.getTempDir(); const runtimeDir = `${tempDir}/ecs-runtime`; // Create runtime directory const dirExists = await TauriAPI.pathExists(runtimeDir); if (!dirExists) { await TauriAPI.createDirectory(runtimeDir); } // Use RuntimeResolver to copy runtime files // 使用 RuntimeResolver 复制运行时文件 const runtimeResolver = RuntimeResolver.getInstance(); await runtimeResolver.initialize(); await runtimeResolver.prepareRuntimeFiles(runtimeDir); // Write scene data and HTML (always update) await TauriAPI.writeFileContent(`${runtimeDir}/scene.json`, sceneData); // Copy project config file (for plugin settings) // 复制项目配置文件(用于插件设置) const projectService = Core.services.tryResolve(ProjectService); const projectPath = projectService?.getCurrentProject()?.path; if (projectPath) { const configPath = `${projectPath}\\ecs-editor.config.json`; const configExists = await TauriAPI.pathExists(configPath); if (configExists) { await TauriAPI.copyFile(configPath, `${runtimeDir}\\ecs-editor.config.json`); console.log('[Viewport] Copied project config to runtime dir'); } } // Create assets directory // 创建资产目录 const assetsDir = `${runtimeDir}\\assets`; const assetsDirExists = await TauriAPI.pathExists(assetsDir); if (!assetsDirExists) { await TauriAPI.createDirectory(assetsDir); } // Collect all asset paths from scene // 从场景中收集所有资产路径 const sceneObj = JSON.parse(sceneData); const assetPaths = new Set(); // Scan all components for asset references if (sceneObj.entities) { for (const entity of sceneObj.entities) { if (entity.components) { for (const comp of entity.components) { // Sprite textures if (comp.type === 'Sprite' && comp.data?.texture) { assetPaths.add(comp.data.texture); } // Behavior tree assets if (comp.type === 'BehaviorTreeRuntime' && comp.data?.treeAssetId) { assetPaths.add(comp.data.treeAssetId); } // Tilemap assets if (comp.type === 'Tilemap' && comp.data?.tmxPath) { assetPaths.add(comp.data.tmxPath); } // Audio assets if (comp.type === 'AudioSource' && comp.data?.clip) { assetPaths.add(comp.data.clip); } } } } } // Build asset catalog and copy files // 构建资产目录并复制文件 const catalogEntries: Record = {}; for (const assetPath of assetPaths) { if (!assetPath || (!assetPath.includes(':\\') && !assetPath.startsWith('/'))) continue; try { const exists = await TauriAPI.pathExists(assetPath); if (!exists) { console.warn(`[Viewport] Asset not found: ${assetPath}`); continue; } // Get filename and determine relative path const filename = assetPath.split(/[/\\]/).pop() || ''; const destPath = `${assetsDir}\\${filename}`; const relativePath = `assets/${filename}`; // Copy file await TauriAPI.copyFile(assetPath, destPath); // Determine asset type from extension const ext = filename.substring(filename.lastIndexOf('.')).toLowerCase(); const typeMap: Record = { '.png': 'texture', '.jpg': 'texture', '.jpeg': 'texture', '.webp': 'texture', '.btree': 'btree', '.tmx': 'tilemap', '.tsx': 'tileset', '.mp3': 'audio', '.ogg': 'audio', '.wav': 'audio', '.json': 'json' }; const assetType = typeMap[ext] || 'binary'; // Generate simple GUID based on path const guid = assetPath.replace(/[^a-zA-Z0-9]/g, '-').substring(0, 36); catalogEntries[guid] = { guid, path: relativePath, type: assetType, size: 0, hash: '' }; console.log(`[Viewport] Copied asset: ${filename}`); } catch (error) { console.error(`[Viewport] Failed to copy asset ${assetPath}:`, error); } } // Write asset catalog // 写入资产目录 const assetCatalog = { version: '1.0.0', createdAt: Date.now(), entries: catalogEntries }; await TauriAPI.writeFileContent(`${runtimeDir}/asset-catalog.json`, JSON.stringify(assetCatalog, null, 2)); console.log(`[Viewport] Asset catalog created with ${Object.keys(catalogEntries).length} entries`); const runtimeHtml = generateRuntimeHtml(); await TauriAPI.writeFileContent(`${runtimeDir}/index.html`, runtimeHtml); // Start local server and open browser const serverUrl = await TauriAPI.startLocalServer(runtimeDir, 3333); await open(serverUrl); messageHub?.publish('notification:success', { title: locale === 'zh' ? '浏览器运行' : 'Run in Browser', message: locale === 'zh' ? `已在浏览器中打开: ${serverUrl}` : `Opened in browser: ${serverUrl}` }); } catch (error) { console.error('Failed to run in browser:', error); messageHub?.publish('notification:error', { title: locale === 'zh' ? '运行失败' : 'Run Failed', message: String(error) }); } }; const handleRunOnDevice = async () => { setShowRunMenu(false); if (!Core.scene) { if (messageHub) { messageHub.publish('notification:warning', { title: locale === 'zh' ? '无场景' : 'No Scene', message: locale === 'zh' ? '请先创建场景' : 'Please create a scene first' }); } return; } try { // Get scene data const sceneData = SceneSerializer.serialize(Core.scene); // Get temp directory and create runtime folder const tempDir = await TauriAPI.getTempDir(); const runtimeDir = `${tempDir}\\ecs-device-preview`; // Create directory const dirExists = await TauriAPI.pathExists(runtimeDir); if (!dirExists) { await TauriAPI.createDirectory(runtimeDir); } // Use RuntimeResolver to copy runtime files const runtimeResolver = RuntimeResolver.getInstance(); await runtimeResolver.initialize(); await runtimeResolver.prepareRuntimeFiles(runtimeDir); // Copy project config file (for plugin settings) const projectService = Core.services.tryResolve(ProjectService); if (projectService) { const currentProject = projectService.getCurrentProject(); if (currentProject?.path) { const configPath = `${currentProject.path}\\ecs-editor.config.json`; const configExists = await TauriAPI.pathExists(configPath); if (configExists) { await TauriAPI.copyFile(configPath, `${runtimeDir}\\ecs-editor.config.json`); } } } // Write scene data and HTML const sceneDataStr = typeof sceneData === 'string' ? sceneData : new TextDecoder().decode(sceneData); await TauriAPI.writeFileContent(`${runtimeDir}/scene.json`, sceneDataStr); await TauriAPI.writeFileContent(`${runtimeDir}/index.html`, generateRuntimeHtml()); // Copy textures referenced in scene const assetsDir = `${runtimeDir}\\assets`; const assetsDirExists = await TauriAPI.pathExists(assetsDir); if (!assetsDirExists) { await TauriAPI.createDirectory(assetsDir); } // Collect texture paths from scene data const texturePathSet = new Set(); try { const entityData = JSON.parse(sceneDataStr); if (entityData.entities) { for (const ent of entityData.entities) { if (ent.components) { for (const comp of ent.components) { if (comp.texture && typeof comp.texture === 'string') { texturePathSet.add(comp.texture); } } } } } } catch (e) { console.error('Failed to parse scene data for textures:', e); } // Copy texture files for (const texturePath of texturePathSet) { if (texturePath && (texturePath.includes(':\\') || texturePath.startsWith('/'))) { try { const filename = texturePath.split(/[/\\]/).pop() || ''; const destPath = `${assetsDir}\\${filename}`; const exists = await TauriAPI.pathExists(texturePath); if (exists) { await TauriAPI.copyFile(texturePath, destPath); } } catch (error) { console.error(`Failed to copy texture ${texturePath}:`, error); } } } // Get local IP and start server const localIp = await TauriAPI.getLocalIp(); const port = 3333; await TauriAPI.startLocalServer(runtimeDir, port); // Generate preview URL const previewUrl = `http://${localIp}:${port}`; setDevicePreviewUrl(previewUrl); setShowQRDialog(true); if (messageHub) { messageHub.publish('notification:success', { title: locale === 'zh' ? '服务器已启动' : 'Server Started', message: locale === 'zh' ? `预览地址: ${previewUrl}` : `Preview URL: ${previewUrl}` }); } } catch (error) { console.error('Failed to run on device:', error); if (messageHub) { messageHub.publish('notification:error', { title: locale === 'zh' ? '启动失败' : 'Failed to Start', message: error instanceof Error ? error.message : String(error) }); } } }; // Update refs after function definitions handleRunInBrowserRef.current = handleRunInBrowser; handleRunOnDeviceRef.current = handleRunOnDevice; // Subscribe to MainToolbar events useEffect(() => { if (!messageHub) return; const unsubscribeStart = messageHub.subscribe('preview:start', () => { handlePlayRef.current(); messageHub.publish('preview:started', {}); }); const unsubscribePause = messageHub.subscribe('preview:pause', () => { handlePauseRef.current(); messageHub.publish('preview:paused', {}); }); const unsubscribeStop = messageHub.subscribe('preview:stop', () => { handleStopRef.current(); messageHub.publish('preview:stopped', {}); }); const unsubscribeStep = messageHub.subscribe('preview:step', () => { engine.step(); }); const unsubscribeRunBrowser = messageHub.subscribe('viewport:run-in-browser', () => { handleRunInBrowserRef.current?.(); }); const unsubscribeRunDevice = messageHub.subscribe('viewport:run-on-device', () => { handleRunOnDeviceRef.current?.(); }); return () => { unsubscribeStart(); unsubscribePause(); unsubscribeStop(); unsubscribeStep(); unsubscribeRunBrowser(); unsubscribeRunDevice(); }; }, [messageHub]); const handleFullscreen = () => { if (containerRef.current) { if (document.fullscreenElement) { document.exitFullscreen(); } else { containerRef.current.requestFullscreen(); } } }; // Keyboard shortcuts for transform tools const handleKeyDown = useCallback((e: KeyboardEvent) => { // Don't handle if input is focused const activeElement = document.activeElement; if (activeElement instanceof HTMLInputElement || activeElement instanceof HTMLTextAreaElement) { return; } switch (e.key.toLowerCase()) { case 'q': setTransformMode('select'); break; case 'w': setTransformMode('move'); break; case 'e': setTransformMode('rotate'); break; case 'r': setTransformMode('scale'); break; } }, []); useEffect(() => { window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [handleKeyDown]); const gridSnapOptions = [1, 5, 10, 25, 50, 100]; const rotationSnapOptions = [5, 10, 15, 30, 45, 90]; const scaleSnapOptions = [0.1, 0.25, 0.5, 1]; const closeAllSnapMenus = useCallback(() => { setShowGridSnapMenu(false); setShowRotationSnapMenu(false); setShowScaleSnapMenu(false); setShowRunMenu(false); }, []); // Close menus when clicking outside useEffect(() => { const handleClickOutside = (e: MouseEvent) => { const target = e.target as Node; if (gridSnapMenuRef.current && !gridSnapMenuRef.current.contains(target)) { setShowGridSnapMenu(false); } if (rotationSnapMenuRef.current && !rotationSnapMenuRef.current.contains(target)) { setShowRotationSnapMenu(false); } if (scaleSnapMenuRef.current && !scaleSnapMenuRef.current.contains(target)) { setShowScaleSnapMenu(false); } if (runMenuRef.current && !runMenuRef.current.contains(target)) { setShowRunMenu(false); } }; document.addEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside); }, []); return (
{/* Internal Overlay Toolbar */}
{/* Transform tools */}
{/* Snap toggle */} {/* Grid Snap Value */}
{showGridSnapMenu && (
{gridSnapOptions.map((val) => ( ))}
)}
{/* Rotation Snap Value */}
{showRotationSnapMenu && (
{rotationSnapOptions.map((val) => ( ))}
)}
{/* Scale Snap Value */}
{showScaleSnapMenu && (
{scaleSnapOptions.map((val) => ( ))}
)}
{/* View options */}
{/* Zoom display */}
{Math.round(camera2DZoom * 100)}%
{/* Stats toggle */} {/* Reset view */} {/* Fullscreen */}
{/* Run options */}
{showRunMenu && (
)}
{showStats && (
FPS: {engine.state.fps}
Draw Calls: {engine.state.drawCalls}
Sprites: {engine.state.spriteCount}
{engine.state.error && (
Error: {engine.state.error}
)}
)} setShowQRDialog(false)} />
); }