* refactor: 编辑器/运行时架构拆分与构建系统升级 * feat(core): 层级系统重构与UI变换矩阵修复 * refactor: 移除 ecs-components 聚合包并修复跨包组件查找问题 * fix(physics): 修复跨包组件类引用问题 * feat: 统一运行时架构与浏览器运行支持 * feat(asset): 实现浏览器运行时资产加载系统 * fix: 修复文档、CodeQL安全问题和CI类型检查错误 * fix: 修复文档、CodeQL安全问题和CI类型检查错误 * fix: 修复文档、CodeQL安全问题、CI类型检查和测试错误 * test: 补齐核心模块测试用例,修复CI构建配置 * fix: 修复测试用例中的类型错误和断言问题 * fix: 修复 turbo build:npm 任务的依赖顺序问题 * fix: 修复 CI 构建错误并优化构建性能
1318 lines
55 KiB
TypeScript
1318 lines
55 KiB
TypeScript
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 `<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>ECS Runtime Preview</title>
|
||
<style>
|
||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||
html, body {
|
||
background: #1e1e1e;
|
||
margin: 0;
|
||
padding: 0;
|
||
overflow: hidden;
|
||
width: 100%;
|
||
height: 100%;
|
||
position: fixed;
|
||
}
|
||
canvas {
|
||
display: block;
|
||
touch-action: none;
|
||
user-select: none;
|
||
-webkit-user-select: none;
|
||
-webkit-user-drag: none;
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<canvas id="runtime-canvas"></canvas>
|
||
<script src="/runtime.browser.js"></script>
|
||
<script type="module">
|
||
import * as esEngine from '/es_engine.js';
|
||
(async function() {
|
||
try {
|
||
// Set canvas size before creating runtime
|
||
const canvas = document.getElementById('runtime-canvas');
|
||
canvas.width = window.innerWidth;
|
||
canvas.height = window.innerHeight;
|
||
|
||
const runtime = ECSRuntime.create({
|
||
canvasId: 'runtime-canvas',
|
||
width: window.innerWidth,
|
||
height: window.innerHeight,
|
||
projectConfigUrl: '/ecs-editor.config.json'
|
||
});
|
||
|
||
await runtime.initialize(esEngine);
|
||
await runtime.loadScene('/scene.json?_=' + Date.now());
|
||
runtime.start();
|
||
|
||
window.addEventListener('resize', () => {
|
||
const canvas = document.getElementById('runtime-canvas');
|
||
const newWidth = window.innerWidth;
|
||
const newHeight = window.innerHeight;
|
||
canvas.width = newWidth;
|
||
canvas.height = newHeight;
|
||
runtime.handleResize(newWidth, newHeight);
|
||
});
|
||
} catch (e) {
|
||
console.error('Runtime error:', e);
|
||
}
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>`;
|
||
}
|
||
|
||
// 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<HTMLCanvasElement>(null);
|
||
const containerRef = useRef<HTMLDivElement>(null);
|
||
const [playState, setPlayState] = useState<PlayState>('stopped');
|
||
const [showGrid, setShowGrid] = useState(true);
|
||
const [showGizmos, setShowGizmos] = useState(true);
|
||
const [showStats, setShowStats] = useState(false);
|
||
const [transformMode, setTransformMode] = useState<TransformMode>('select');
|
||
const [showRunMenu, setShowRunMenu] = useState(false);
|
||
const [showQRDialog, setShowQRDialog] = useState(false);
|
||
const [devicePreviewUrl, setDevicePreviewUrl] = useState('');
|
||
const runMenuRef = useRef<HTMLDivElement>(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<HTMLDivElement>(null);
|
||
const rotationSnapMenuRef = useRef<HTMLDivElement>(null);
|
||
const scaleSnapMenuRef = useRef<HTMLDivElement>(null);
|
||
|
||
// Store editor camera state when entering play mode
|
||
const editorCameraRef = useRef({ x: 0, y: 0, zoom: 1 });
|
||
const playStateRef = useRef<PlayState>('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<Entity | null>(null);
|
||
const messageHubRef = useRef<MessageHub | null>(null);
|
||
const transformModeRef = useRef<TransformMode>('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<string>();
|
||
|
||
// 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<string, { guid: string; path: string; type: string; size: number; hash: string }> = {};
|
||
|
||
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<string, string> = {
|
||
'.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<string>();
|
||
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 (
|
||
<div className="viewport" ref={containerRef}>
|
||
{/* Internal Overlay Toolbar */}
|
||
<div className="viewport-internal-toolbar">
|
||
<div className="viewport-internal-toolbar-left">
|
||
{/* Transform tools */}
|
||
<div className="viewport-btn-group">
|
||
<button
|
||
className={`viewport-btn ${transformMode === 'select' ? 'active' : ''}`}
|
||
onClick={() => setTransformMode('select')}
|
||
title={locale === 'zh' ? '选择 (Q)' : 'Select (Q)'}
|
||
>
|
||
<MousePointer2 size={14} />
|
||
</button>
|
||
<button
|
||
className={`viewport-btn ${transformMode === 'move' ? 'active' : ''}`}
|
||
onClick={() => setTransformMode('move')}
|
||
title={locale === 'zh' ? '移动 (W)' : 'Move (W)'}
|
||
>
|
||
<Move size={14} />
|
||
</button>
|
||
<button
|
||
className={`viewport-btn ${transformMode === 'rotate' ? 'active' : ''}`}
|
||
onClick={() => setTransformMode('rotate')}
|
||
title={locale === 'zh' ? '旋转 (E)' : 'Rotate (E)'}
|
||
>
|
||
<RotateCw size={14} />
|
||
</button>
|
||
<button
|
||
className={`viewport-btn ${transformMode === 'scale' ? 'active' : ''}`}
|
||
onClick={() => setTransformMode('scale')}
|
||
title={locale === 'zh' ? '缩放 (R)' : 'Scale (R)'}
|
||
>
|
||
<Scaling size={14} />
|
||
</button>
|
||
</div>
|
||
|
||
<div className="viewport-divider" />
|
||
|
||
{/* Snap toggle */}
|
||
<button
|
||
className={`viewport-btn ${snapEnabled ? 'active' : ''}`}
|
||
onClick={() => setSnapEnabled(!snapEnabled)}
|
||
title={locale === 'zh' ? '吸附开关' : 'Toggle Snap'}
|
||
>
|
||
<Magnet size={14} />
|
||
</button>
|
||
|
||
{/* Grid Snap Value */}
|
||
<div className="viewport-snap-dropdown" ref={gridSnapMenuRef}>
|
||
<button
|
||
className="viewport-snap-btn"
|
||
onClick={() => { closeAllSnapMenus(); setShowGridSnapMenu(!showGridSnapMenu); }}
|
||
title={locale === 'zh' ? '网格吸附' : 'Grid Snap'}
|
||
>
|
||
<Grid3x3 size={12} />
|
||
<span>{gridSnapValue}</span>
|
||
<ChevronDown size={10} />
|
||
</button>
|
||
{showGridSnapMenu && (
|
||
<div className="viewport-snap-menu">
|
||
{gridSnapOptions.map((val) => (
|
||
<button
|
||
key={val}
|
||
className={gridSnapValue === val ? 'active' : ''}
|
||
onClick={() => { setGridSnapValue(val); setShowGridSnapMenu(false); }}
|
||
>
|
||
{val}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Rotation Snap Value */}
|
||
<div className="viewport-snap-dropdown" ref={rotationSnapMenuRef}>
|
||
<button
|
||
className="viewport-snap-btn"
|
||
onClick={() => { closeAllSnapMenus(); setShowRotationSnapMenu(!showRotationSnapMenu); }}
|
||
title={locale === 'zh' ? '旋转吸附' : 'Rotation Snap'}
|
||
>
|
||
<RotateCw size={12} />
|
||
<span>{rotationSnapValue}°</span>
|
||
<ChevronDown size={10} />
|
||
</button>
|
||
{showRotationSnapMenu && (
|
||
<div className="viewport-snap-menu">
|
||
{rotationSnapOptions.map((val) => (
|
||
<button
|
||
key={val}
|
||
className={rotationSnapValue === val ? 'active' : ''}
|
||
onClick={() => { setRotationSnapValue(val); setShowRotationSnapMenu(false); }}
|
||
>
|
||
{val}°
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Scale Snap Value */}
|
||
<div className="viewport-snap-dropdown" ref={scaleSnapMenuRef}>
|
||
<button
|
||
className="viewport-snap-btn"
|
||
onClick={() => { closeAllSnapMenus(); setShowScaleSnapMenu(!showScaleSnapMenu); }}
|
||
title={locale === 'zh' ? '缩放吸附' : 'Scale Snap'}
|
||
>
|
||
<Scaling size={12} />
|
||
<span>{scaleSnapValue}</span>
|
||
<ChevronDown size={10} />
|
||
</button>
|
||
{showScaleSnapMenu && (
|
||
<div className="viewport-snap-menu">
|
||
{scaleSnapOptions.map((val) => (
|
||
<button
|
||
key={val}
|
||
className={scaleSnapValue === val ? 'active' : ''}
|
||
onClick={() => { setScaleSnapValue(val); setShowScaleSnapMenu(false); }}
|
||
>
|
||
{val}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="viewport-internal-toolbar-right">
|
||
{/* View options */}
|
||
<button
|
||
className={`viewport-btn ${showGrid ? 'active' : ''}`}
|
||
onClick={() => setShowGrid(!showGrid)}
|
||
title={locale === 'zh' ? '显示网格' : 'Show Grid'}
|
||
>
|
||
<Grid3x3 size={14} />
|
||
</button>
|
||
<button
|
||
className={`viewport-btn ${showGizmos ? 'active' : ''}`}
|
||
onClick={() => setShowGizmos(!showGizmos)}
|
||
title={locale === 'zh' ? '显示辅助线' : 'Show Gizmos'}
|
||
>
|
||
{showGizmos ? <Eye size={14} /> : <EyeOff size={14} />}
|
||
</button>
|
||
|
||
<div className="viewport-divider" />
|
||
|
||
{/* Zoom display */}
|
||
<div className="viewport-zoom-display">
|
||
<ZoomIn size={12} />
|
||
<span>{Math.round(camera2DZoom * 100)}%</span>
|
||
</div>
|
||
|
||
<div className="viewport-divider" />
|
||
|
||
{/* Stats toggle */}
|
||
<button
|
||
className={`viewport-btn ${showStats ? 'active' : ''}`}
|
||
onClick={() => setShowStats(!showStats)}
|
||
title={locale === 'zh' ? '统计信息' : 'Stats'}
|
||
>
|
||
<Activity size={14} />
|
||
</button>
|
||
|
||
{/* Reset view */}
|
||
<button
|
||
className="viewport-btn"
|
||
onClick={handleReset}
|
||
title={locale === 'zh' ? '重置视图' : 'Reset View'}
|
||
>
|
||
<RotateCcw size={14} />
|
||
</button>
|
||
|
||
{/* Fullscreen */}
|
||
<button
|
||
className="viewport-btn"
|
||
onClick={handleFullscreen}
|
||
title={locale === 'zh' ? '全屏' : 'Fullscreen'}
|
||
>
|
||
<Maximize2 size={14} />
|
||
</button>
|
||
|
||
<div className="viewport-divider" />
|
||
|
||
{/* Run options */}
|
||
<div className="viewport-snap-dropdown" ref={runMenuRef}>
|
||
<button
|
||
className="viewport-snap-btn"
|
||
onClick={() => { closeAllSnapMenus(); setShowRunMenu(!showRunMenu); }}
|
||
title={locale === 'zh' ? '运行选项' : 'Run Options'}
|
||
>
|
||
<Globe size={14} />
|
||
<ChevronDown size={10} />
|
||
</button>
|
||
{showRunMenu && (
|
||
<div className="viewport-snap-menu viewport-snap-menu-right">
|
||
<button onClick={handleRunInBrowser}>
|
||
<Globe size={14} />
|
||
{locale === 'zh' ? '浏览器运行' : 'Run in Browser'}
|
||
</button>
|
||
<button onClick={handleRunOnDevice}>
|
||
<QrCode size={14} />
|
||
{locale === 'zh' ? '真机运行' : 'Run on Device'}
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<canvas ref={canvasRef} id="viewport-canvas" className="viewport-canvas" />
|
||
|
||
{showStats && (
|
||
<div className="viewport-stats">
|
||
<div className="viewport-stat">
|
||
<span className="viewport-stat-label">FPS:</span>
|
||
<span className="viewport-stat-value">{engine.state.fps}</span>
|
||
</div>
|
||
<div className="viewport-stat">
|
||
<span className="viewport-stat-label">Draw Calls:</span>
|
||
<span className="viewport-stat-value">{engine.state.drawCalls}</span>
|
||
</div>
|
||
<div className="viewport-stat">
|
||
<span className="viewport-stat-label">Sprites:</span>
|
||
<span className="viewport-stat-value">{engine.state.spriteCount}</span>
|
||
</div>
|
||
{engine.state.error && (
|
||
<div className="viewport-stat viewport-stat-error">
|
||
<span className="viewport-stat-label">Error:</span>
|
||
<span className="viewport-stat-value">{engine.state.error}</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
<QRCodeDialog
|
||
url={devicePreviewUrl}
|
||
isOpen={showQRDialog}
|
||
onClose={() => setShowQRDialog(false)}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|