2025-11-23 14:49:37 +08:00
|
|
|
|
import { useEffect, useRef, useState, useCallback } from 'react';
|
2025-11-29 23:00:48 +08:00
|
|
|
|
import {
|
|
|
|
|
|
RotateCcw, Maximize2, Grid3x3, Eye, EyeOff, Activity,
|
|
|
|
|
|
MousePointer2, Move, RotateCw, Scaling, Globe, QrCode, ChevronDown,
|
|
|
|
|
|
Magnet, ZoomIn
|
|
|
|
|
|
} from 'lucide-react';
|
2025-10-15 17:28:45 +08:00
|
|
|
|
import '../styles/Viewport.css';
|
2025-11-21 10:03:18 +08:00
|
|
|
|
import { useEngine } from '../hooks/useEngine';
|
2025-11-23 14:49:37 +08:00
|
|
|
|
import { EngineService } from '../services/EngineService';
|
|
|
|
|
|
import { Core, Entity, SceneSerializer } from '@esengine/ecs-framework';
|
2025-12-01 22:28:51 +08:00
|
|
|
|
import { MessageHub, ProjectService, AssetRegistryService } from '@esengine/editor-core';
|
|
|
|
|
|
import { TransformComponent } from '@esengine/engine-core';
|
|
|
|
|
|
import { CameraComponent } from '@esengine/camera';
|
2025-11-26 11:08:10 +08:00
|
|
|
|
import { UITransformComponent } from '@esengine/ui';
|
2025-11-23 14:49:37 +08:00
|
|
|
|
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">
|
2025-11-25 22:23:19 +08:00
|
|
|
|
import * as esEngine from '/es_engine.js';
|
2025-11-23 14:49:37 +08:00
|
|
|
|
(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,
|
2025-12-01 22:28:51 +08:00
|
|
|
|
height: window.innerHeight,
|
|
|
|
|
|
projectConfigUrl: '/ecs-editor.config.json'
|
2025-11-23 14:49:37 +08:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
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';
|
2025-10-15 17:28:45 +08:00
|
|
|
|
|
|
|
|
|
|
interface ViewportProps {
|
|
|
|
|
|
locale?: string;
|
2025-11-23 14:49:37 +08:00
|
|
|
|
messageHub?: MessageHub;
|
2025-10-15 17:28:45 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-23 14:49:37 +08:00
|
|
|
|
export function Viewport({ locale = 'en', messageHub }: ViewportProps) {
|
2025-11-02 23:50:41 +08:00
|
|
|
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
|
|
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
2025-11-23 14:49:37 +08:00
|
|
|
|
const [playState, setPlayState] = useState<PlayState>('stopped');
|
2025-11-02 23:50:41 +08:00
|
|
|
|
const [showGrid, setShowGrid] = useState(true);
|
|
|
|
|
|
const [showGizmos, setShowGizmos] = useState(true);
|
|
|
|
|
|
const [showStats, setShowStats] = useState(false);
|
2025-11-23 14:49:37 +08:00
|
|
|
|
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);
|
|
|
|
|
|
|
2025-11-29 23:00:48 +08:00
|
|
|
|
// 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);
|
|
|
|
|
|
|
2025-11-23 14:49:37 +08:00
|
|
|
|
// 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
|
2025-11-02 23:50:41 +08:00
|
|
|
|
const [camera2DOffset, setCamera2DOffset] = useState({ x: 0, y: 0 });
|
2025-11-23 14:49:37 +08:00
|
|
|
|
const [camera2DZoom, setCamera2DZoom] = useState(1);
|
|
|
|
|
|
const camera2DZoomRef = useRef(1);
|
|
|
|
|
|
const camera2DOffsetRef = useRef({ x: 0, y: 0 });
|
|
|
|
|
|
const isDraggingCameraRef = useRef(false);
|
|
|
|
|
|
const isDraggingTransformRef = useRef(false);
|
2025-11-02 23:50:41 +08:00
|
|
|
|
const lastMousePosRef = useRef({ x: 0, y: 0 });
|
2025-11-23 14:49:37 +08:00
|
|
|
|
const selectedEntityRef = useRef<Entity | null>(null);
|
|
|
|
|
|
const messageHubRef = useRef<MessageHub | null>(null);
|
|
|
|
|
|
const transformModeRef = useRef<TransformMode>('select');
|
2025-11-29 23:00:48 +08:00
|
|
|
|
const snapEnabledRef = useRef(true);
|
|
|
|
|
|
const gridSnapRef = useRef(10);
|
|
|
|
|
|
const rotationSnapRef = useRef(15);
|
|
|
|
|
|
const scaleSnapRef = useRef(0.25);
|
2025-11-23 14:49:37 +08:00
|
|
|
|
|
|
|
|
|
|
// Keep refs in sync with state
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
camera2DZoomRef.current = camera2DZoom;
|
|
|
|
|
|
}, [camera2DZoom]);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
camera2DOffsetRef.current = camera2DOffset;
|
|
|
|
|
|
}, [camera2DOffset]);
|
2025-11-02 23:50:41 +08:00
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
2025-11-23 14:49:37 +08:00
|
|
|
|
transformModeRef.current = transformMode;
|
|
|
|
|
|
}, [transformMode]);
|
|
|
|
|
|
|
2025-11-29 23:00:48 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
2025-11-23 14:49:37 +08:00
|
|
|
|
// Screen to world coordinate conversion - uses refs to avoid re-registering event handlers
|
|
|
|
|
|
const screenToWorld = useCallback((screenX: number, screenY: number) => {
|
2025-11-02 23:50:41 +08:00
|
|
|
|
const canvas = canvasRef.current;
|
2025-11-23 14:49:37 +08:00
|
|
|
|
if (!canvas) return { x: 0, y: 0 };
|
2025-11-02 23:50:41 +08:00
|
|
|
|
|
2025-11-23 14:49:37 +08:00
|
|
|
|
const rect = canvas.getBoundingClientRect();
|
|
|
|
|
|
const dpr = window.devicePixelRatio || 1;
|
2025-11-02 23:50:41 +08:00
|
|
|
|
|
2025-11-23 14:49:37 +08:00
|
|
|
|
// 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();
|
2025-11-02 23:50:41 +08:00
|
|
|
|
}
|
2025-11-23 14:49:37 +08:00
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
// Canvas setup and input handling
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const canvas = canvasRef.current;
|
|
|
|
|
|
if (!canvas) return;
|
2025-11-02 23:50:41 +08:00
|
|
|
|
|
2025-11-23 14:49:37 +08:00
|
|
|
|
canvas.style.cursor = 'grab';
|
2025-11-02 23:50:41 +08:00
|
|
|
|
|
|
|
|
|
|
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`;
|
|
|
|
|
|
|
2025-11-23 14:49:37 +08:00
|
|
|
|
EngineService.getInstance().resize(canvas.width, canvas.height);
|
2025-11-02 23:50:41 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
resizeCanvas();
|
|
|
|
|
|
window.addEventListener('resize', resizeCanvas);
|
|
|
|
|
|
|
2025-11-29 23:00:48 +08:00
|
|
|
|
let rafId: number | null = null;
|
2025-11-02 23:50:41 +08:00
|
|
|
|
const resizeObserver = new ResizeObserver(() => {
|
2025-11-29 23:00:48 +08:00
|
|
|
|
// 使用 requestAnimationFrame 避免 ResizeObserver loop 错误
|
|
|
|
|
|
// Use requestAnimationFrame to avoid ResizeObserver loop errors
|
|
|
|
|
|
if (rafId !== null) {
|
|
|
|
|
|
cancelAnimationFrame(rafId);
|
|
|
|
|
|
}
|
|
|
|
|
|
rafId = requestAnimationFrame(() => {
|
|
|
|
|
|
resizeCanvas();
|
|
|
|
|
|
rafId = null;
|
|
|
|
|
|
});
|
2025-11-02 23:50:41 +08:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (containerRef.current) {
|
|
|
|
|
|
resizeObserver.observe(containerRef.current);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const handleMouseDown = (e: MouseEvent) => {
|
2025-11-23 14:49:37 +08:00
|
|
|
|
// 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;
|
2025-11-02 23:50:41 +08:00
|
|
|
|
lastMousePosRef.current = { x: e.clientX, y: e.clientY };
|
|
|
|
|
|
canvas.style.cursor = 'grabbing';
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
}
|
2025-11-23 14:49:37 +08:00
|
|
|
|
// 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();
|
|
|
|
|
|
}
|
2025-11-02 23:50:41 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleMouseMove = (e: MouseEvent) => {
|
|
|
|
|
|
const deltaX = e.clientX - lastMousePosRef.current.x;
|
|
|
|
|
|
const deltaY = e.clientY - lastMousePosRef.current.y;
|
|
|
|
|
|
|
2025-11-23 14:49:37 +08:00
|
|
|
|
if (isDraggingCameraRef.current) {
|
|
|
|
|
|
// Camera pan - use ref to avoid stale closure
|
|
|
|
|
|
const dpr = window.devicePixelRatio || 1;
|
|
|
|
|
|
const zoom = camera2DZoomRef.current;
|
2025-11-02 23:50:41 +08:00
|
|
|
|
setCamera2DOffset((prev) => ({
|
2025-11-23 14:49:37 +08:00
|
|
|
|
x: prev.x - (deltaX * dpr) / zoom,
|
|
|
|
|
|
y: prev.y + (deltaY * dpr) / zoom
|
2025-11-02 23:50:41 +08:00
|
|
|
|
}));
|
2025-11-23 14:49:37 +08:00
|
|
|
|
} 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;
|
2025-11-26 11:08:10 +08:00
|
|
|
|
|
|
|
|
|
|
// 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';
|
2025-12-01 22:28:51 +08:00
|
|
|
|
const value = propertyName === 'position' ? transform.position :
|
|
|
|
|
|
propertyName === 'rotation' ? transform.rotation : transform.scale;
|
2025-11-26 11:08:10 +08:00
|
|
|
|
messageHubRef.current.publish('component:property:changed', {
|
|
|
|
|
|
entity,
|
|
|
|
|
|
component: transform,
|
|
|
|
|
|
propertyName,
|
2025-12-01 22:28:51 +08:00
|
|
|
|
value
|
2025-11-26 11:08:10 +08:00
|
|
|
|
});
|
2025-11-23 14:49:37 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-26 11:08:10 +08:00
|
|
|
|
// 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') {
|
2025-12-01 22:28:51 +08:00
|
|
|
|
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);
|
|
|
|
|
|
|
2025-11-26 11:08:10 +08:00
|
|
|
|
if (startDist > 0) {
|
|
|
|
|
|
const scaleFactor = endDist / startDist;
|
2025-12-01 22:28:51 +08:00
|
|
|
|
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;
|
2025-11-26 11:08:10 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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]
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2025-11-23 14:49:37 +08:00
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
return;
|
2025-11-02 23:50:41 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
lastMousePosRef.current = { x: e.clientX, y: e.clientY };
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleMouseUp = () => {
|
2025-11-23 14:49:37 +08:00
|
|
|
|
if (isDraggingCameraRef.current) {
|
|
|
|
|
|
isDraggingCameraRef.current = false;
|
2025-11-02 23:50:41 +08:00
|
|
|
|
canvas.style.cursor = 'grab';
|
|
|
|
|
|
}
|
2025-11-23 14:49:37 +08:00
|
|
|
|
if (isDraggingTransformRef.current) {
|
|
|
|
|
|
isDraggingTransformRef.current = false;
|
|
|
|
|
|
canvas.style.cursor = 'grab';
|
|
|
|
|
|
|
2025-11-29 23:00:48 +08:00
|
|
|
|
// 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-23 14:49:37 +08:00
|
|
|
|
// 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();
|
2025-11-02 23:50:41 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleWheel = (e: WheelEvent) => {
|
|
|
|
|
|
e.preventDefault();
|
2025-11-23 14:49:37 +08:00
|
|
|
|
// Disable zoom in play mode
|
|
|
|
|
|
if (playStateRef.current === 'playing') {
|
|
|
|
|
|
return;
|
2025-11-02 23:50:41 +08:00
|
|
|
|
}
|
2025-11-23 14:49:37 +08:00
|
|
|
|
// 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)));
|
2025-11-02 23:50:41 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
canvas.addEventListener('mousedown', handleMouseDown);
|
|
|
|
|
|
canvas.addEventListener('wheel', handleWheel, { passive: false });
|
2025-11-23 14:49:37 +08:00
|
|
|
|
canvas.addEventListener('contextmenu', handleContextMenu);
|
2025-11-02 23:50:41 +08:00
|
|
|
|
document.addEventListener('mousemove', handleMouseMove);
|
|
|
|
|
|
document.addEventListener('mouseup', handleMouseUp);
|
|
|
|
|
|
|
|
|
|
|
|
return () => {
|
2025-11-29 23:00:48 +08:00
|
|
|
|
if (rafId !== null) {
|
|
|
|
|
|
cancelAnimationFrame(rafId);
|
|
|
|
|
|
}
|
2025-11-02 23:50:41 +08:00
|
|
|
|
window.removeEventListener('resize', resizeCanvas);
|
|
|
|
|
|
resizeObserver.disconnect();
|
|
|
|
|
|
canvas.removeEventListener('mousedown', handleMouseDown);
|
|
|
|
|
|
canvas.removeEventListener('wheel', handleWheel);
|
2025-11-23 14:49:37 +08:00
|
|
|
|
canvas.removeEventListener('contextmenu', handleContextMenu);
|
2025-11-02 23:50:41 +08:00
|
|
|
|
document.removeEventListener('mousemove', handleMouseMove);
|
|
|
|
|
|
document.removeEventListener('mouseup', handleMouseUp);
|
|
|
|
|
|
};
|
2025-11-23 14:49:37 +08:00
|
|
|
|
}, []);
|
2025-11-02 23:50:41 +08:00
|
|
|
|
|
2025-11-25 22:23:19 +08:00
|
|
|
|
// Sync camera state to engine and publish camera:updated event
|
|
|
|
|
|
// 同步相机状态到引擎并发布 camera:updated 事件
|
2025-11-02 23:50:41 +08:00
|
|
|
|
useEffect(() => {
|
2025-11-23 14:49:37 +08:00
|
|
|
|
if (engine.state.initialized) {
|
|
|
|
|
|
EngineService.getInstance().setCamera({
|
|
|
|
|
|
x: camera2DOffset.x,
|
|
|
|
|
|
y: camera2DOffset.y,
|
|
|
|
|
|
zoom: camera2DZoom,
|
|
|
|
|
|
rotation: 0
|
|
|
|
|
|
});
|
2025-11-25 22:23:19 +08:00
|
|
|
|
|
|
|
|
|
|
// 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
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2025-11-02 23:50:41 +08:00
|
|
|
|
}
|
2025-11-23 14:49:37 +08:00
|
|
|
|
}, [camera2DOffset, camera2DZoom, engine.state.initialized]);
|
2025-10-15 17:28:45 +08:00
|
|
|
|
|
2025-11-23 14:49:37 +08:00
|
|
|
|
// Sync grid and gizmo visibility
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (engine.state.initialized) {
|
|
|
|
|
|
EngineService.getInstance().setShowGrid(showGrid);
|
|
|
|
|
|
EngineService.getInstance().setShowGizmos(showGizmos);
|
2025-11-02 23:50:41 +08:00
|
|
|
|
}
|
2025-11-23 14:49:37 +08:00
|
|
|
|
}, [showGrid, showGizmos, engine.state.initialized]);
|
2025-11-02 23:50:41 +08:00
|
|
|
|
|
2025-11-23 14:49:37 +08:00
|
|
|
|
// Sync transform mode to engine
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (engine.state.initialized) {
|
|
|
|
|
|
EngineService.getInstance().setTransformMode(transformMode);
|
2025-11-02 23:50:41 +08:00
|
|
|
|
}
|
2025-11-23 14:49:37 +08:00
|
|
|
|
}, [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);
|
2025-11-02 23:50:41 +08:00
|
|
|
|
}
|
2025-11-23 14:49:37 +08:00
|
|
|
|
}, [findPlayerCamera]);
|
2025-11-02 23:50:41 +08:00
|
|
|
|
|
2025-11-23 14:49:37 +08:00
|
|
|
|
// Close run menu when clicking outside
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const handleClickOutside = (e: MouseEvent) => {
|
|
|
|
|
|
if (runMenuRef.current && !runMenuRef.current.contains(e.target as Node)) {
|
|
|
|
|
|
setShowRunMenu(false);
|
2025-11-02 23:50:41 +08:00
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-11-23 14:49:37 +08:00
|
|
|
|
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();
|
2025-11-02 23:50:41 +08:00
|
|
|
|
}
|
2025-10-15 18:08:55 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
2025-11-23 14:49:37 +08:00
|
|
|
|
const handlePause = () => {
|
|
|
|
|
|
if (playState === 'playing') {
|
|
|
|
|
|
setPlayState('paused');
|
|
|
|
|
|
engine.stop();
|
|
|
|
|
|
}
|
2025-10-15 17:28:45 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
2025-11-25 22:23:19 +08:00
|
|
|
|
const handleStop = async () => {
|
2025-11-23 14:49:37 +08:00
|
|
|
|
setPlayState('stopped');
|
|
|
|
|
|
engine.stop();
|
|
|
|
|
|
// Restore scene snapshot
|
2025-11-25 22:23:19 +08:00
|
|
|
|
await EngineService.getInstance().restoreSceneSnapshot();
|
2025-11-23 14:49:37 +08:00
|
|
|
|
// 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);
|
2025-10-15 18:08:55 +08:00
|
|
|
|
};
|
2025-10-15 17:28:45 +08:00
|
|
|
|
|
2025-11-23 14:49:37 +08:00
|
|
|
|
const handleReset = () => {
|
|
|
|
|
|
// Reset camera to origin without stopping playback
|
|
|
|
|
|
setCamera2DOffset({ x: 0, y: 0 });
|
|
|
|
|
|
setCamera2DZoom(1);
|
2025-11-02 23:50:41 +08:00
|
|
|
|
};
|
2025-10-15 17:28:45 +08:00
|
|
|
|
|
2025-11-29 23:00:48 +08:00
|
|
|
|
// 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;
|
|
|
|
|
|
|
2025-11-23 14:49:37 +08:00
|
|
|
|
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;
|
2025-11-02 23:50:41 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-23 14:49:37 +08:00
|
|
|
|
// 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);
|
2025-11-02 23:50:41 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-23 14:49:37 +08:00
|
|
|
|
// 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);
|
|
|
|
|
|
|
2025-12-01 22:28:51 +08:00
|
|
|
|
// 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
|
|
|
|
|
|
// 从场景中收集所有资产路径
|
2025-11-23 14:49:37 +08:00
|
|
|
|
const sceneObj = JSON.parse(sceneData);
|
2025-12-01 22:28:51 +08:00
|
|
|
|
const assetPaths = new Set<string>();
|
2025-11-23 14:49:37 +08:00
|
|
|
|
|
2025-12-01 22:28:51 +08:00
|
|
|
|
// Scan all components for asset references
|
2025-11-23 14:49:37 +08:00
|
|
|
|
if (sceneObj.entities) {
|
|
|
|
|
|
for (const entity of sceneObj.entities) {
|
|
|
|
|
|
if (entity.components) {
|
|
|
|
|
|
for (const comp of entity.components) {
|
2025-12-01 22:28:51 +08:00
|
|
|
|
// Sprite textures
|
2025-11-23 14:49:37 +08:00
|
|
|
|
if (comp.type === 'Sprite' && comp.data?.texture) {
|
2025-12-01 22:28:51 +08:00
|
|
|
|
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);
|
2025-11-23 14:49:37 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-10-15 17:28:45 +08:00
|
|
|
|
|
2025-12-01 22:28:51 +08:00
|
|
|
|
// Build asset catalog and copy files
|
|
|
|
|
|
// 构建资产目录并复制文件
|
|
|
|
|
|
const catalogEntries: Record<string, { guid: string; path: string; type: string; size: number; hash: string }> = {};
|
2025-10-15 17:28:45 +08:00
|
|
|
|
|
2025-12-01 22:28:51 +08:00
|
|
|
|
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;
|
2025-11-23 14:49:37 +08:00
|
|
|
|
}
|
2025-12-01 22:28:51 +08:00
|
|
|
|
|
|
|
|
|
|
// 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);
|
2025-11-23 14:49:37 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-10-15 17:28:45 +08:00
|
|
|
|
|
2025-12-01 22:28:51 +08:00
|
|
|
|
// 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`);
|
|
|
|
|
|
|
2025-11-23 14:49:37 +08:00
|
|
|
|
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)
|
|
|
|
|
|
});
|
2025-11-02 23:50:41 +08:00
|
|
|
|
}
|
2025-10-15 18:08:55 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
2025-11-23 14:49:37 +08:00
|
|
|
|
const handleRunOnDevice = async () => {
|
|
|
|
|
|
setShowRunMenu(false);
|
2025-10-15 18:08:55 +08:00
|
|
|
|
|
2025-11-23 14:49:37 +08:00
|
|
|
|
if (!Core.scene) {
|
|
|
|
|
|
if (messageHub) {
|
|
|
|
|
|
messageHub.publish('notification:warning', {
|
|
|
|
|
|
title: locale === 'zh' ? '无场景' : 'No Scene',
|
|
|
|
|
|
message: locale === 'zh' ? '请先创建场景' : 'Please create a scene first'
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
return;
|
2025-11-02 23:50:41 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-23 14:49:37 +08:00
|
|
|
|
try {
|
|
|
|
|
|
// Get scene data
|
|
|
|
|
|
const sceneData = SceneSerializer.serialize(Core.scene);
|
2025-11-02 23:50:41 +08:00
|
|
|
|
|
2025-11-23 14:49:37 +08:00
|
|
|
|
// Get temp directory and create runtime folder
|
|
|
|
|
|
const tempDir = await TauriAPI.getTempDir();
|
|
|
|
|
|
const runtimeDir = `${tempDir}\\ecs-device-preview`;
|
2025-11-02 23:50:41 +08:00
|
|
|
|
|
2025-11-23 14:49:37 +08:00
|
|
|
|
// Create directory
|
|
|
|
|
|
const dirExists = await TauriAPI.pathExists(runtimeDir);
|
|
|
|
|
|
if (!dirExists) {
|
|
|
|
|
|
await TauriAPI.createDirectory(runtimeDir);
|
|
|
|
|
|
}
|
2025-11-02 23:50:41 +08:00
|
|
|
|
|
2025-11-23 14:49:37 +08:00
|
|
|
|
// Use RuntimeResolver to copy runtime files
|
|
|
|
|
|
const runtimeResolver = RuntimeResolver.getInstance();
|
|
|
|
|
|
await runtimeResolver.initialize();
|
|
|
|
|
|
await runtimeResolver.prepareRuntimeFiles(runtimeDir);
|
|
|
|
|
|
|
2025-12-01 22:28:51 +08:00
|
|
|
|
// 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`);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-23 14:49:37 +08:00
|
|
|
|
// 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);
|
|
|
|
|
|
}
|
2025-10-15 18:08:55 +08:00
|
|
|
|
|
2025-11-23 14:49:37 +08:00
|
|
|
|
// 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);
|
|
|
|
|
|
}
|
2025-11-21 10:03:18 +08:00
|
|
|
|
|
2025-11-23 14:49:37 +08:00
|
|
|
|
// 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-11-21 10:03:18 +08:00
|
|
|
|
}
|
2025-10-15 18:08:55 +08:00
|
|
|
|
|
2025-11-23 14:49:37 +08:00
|
|
|
|
// 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)
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2025-11-02 23:50:41 +08:00
|
|
|
|
}
|
|
|
|
|
|
};
|
2025-10-15 18:08:55 +08:00
|
|
|
|
|
2025-11-29 23:00:48 +08:00
|
|
|
|
// 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]);
|
|
|
|
|
|
|
2025-11-02 23:50:41 +08:00
|
|
|
|
const handleFullscreen = () => {
|
|
|
|
|
|
if (containerRef.current) {
|
|
|
|
|
|
if (document.fullscreenElement) {
|
|
|
|
|
|
document.exitFullscreen();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
containerRef.current.requestFullscreen();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
2025-10-15 18:08:55 +08:00
|
|
|
|
|
2025-11-23 14:49:37 +08:00
|
|
|
|
// 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]);
|
|
|
|
|
|
|
2025-11-29 23:00:48 +08:00
|
|
|
|
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);
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
2025-11-02 23:50:41 +08:00
|
|
|
|
return (
|
|
|
|
|
|
<div className="viewport" ref={containerRef}>
|
2025-11-29 23:00:48 +08:00
|
|
|
|
{/* Internal Overlay Toolbar */}
|
|
|
|
|
|
<div className="viewport-internal-toolbar">
|
|
|
|
|
|
<div className="viewport-internal-toolbar-left">
|
|
|
|
|
|
{/* Transform tools */}
|
2025-11-23 21:45:10 +08:00
|
|
|
|
<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>
|
2025-11-29 23:00:48 +08:00
|
|
|
|
|
2025-11-23 14:49:37 +08:00
|
|
|
|
<div className="viewport-divider" />
|
2025-11-29 23:00:48 +08:00
|
|
|
|
|
|
|
|
|
|
{/* 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}>
|
2025-11-23 21:45:10 +08:00
|
|
|
|
<button
|
2025-11-29 23:00:48 +08:00
|
|
|
|
className="viewport-snap-btn"
|
|
|
|
|
|
onClick={() => { closeAllSnapMenus(); setShowGridSnapMenu(!showGridSnapMenu); }}
|
|
|
|
|
|
title={locale === 'zh' ? '网格吸附' : 'Grid Snap'}
|
2025-11-23 21:45:10 +08:00
|
|
|
|
>
|
2025-11-29 23:00:48 +08:00
|
|
|
|
<Grid3x3 size={12} />
|
|
|
|
|
|
<span>{gridSnapValue}</span>
|
|
|
|
|
|
<ChevronDown size={10} />
|
2025-11-23 21:45:10 +08:00
|
|
|
|
</button>
|
2025-11-29 23:00:48 +08:00
|
|
|
|
{showGridSnapMenu && (
|
|
|
|
|
|
<div className="viewport-snap-menu">
|
|
|
|
|
|
{gridSnapOptions.map((val) => (
|
|
|
|
|
|
<button
|
|
|
|
|
|
key={val}
|
|
|
|
|
|
className={gridSnapValue === val ? 'active' : ''}
|
|
|
|
|
|
onClick={() => { setGridSnapValue(val); setShowGridSnapMenu(false); }}
|
|
|
|
|
|
>
|
|
|
|
|
|
{val}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2025-11-23 21:45:10 +08:00
|
|
|
|
</div>
|
2025-11-29 23:00:48 +08:00
|
|
|
|
|
|
|
|
|
|
{/* Rotation Snap Value */}
|
|
|
|
|
|
<div className="viewport-snap-dropdown" ref={rotationSnapMenuRef}>
|
2025-11-23 14:49:37 +08:00
|
|
|
|
<button
|
2025-11-29 23:00:48 +08:00
|
|
|
|
className="viewport-snap-btn"
|
|
|
|
|
|
onClick={() => { closeAllSnapMenus(); setShowRotationSnapMenu(!showRotationSnapMenu); }}
|
|
|
|
|
|
title={locale === 'zh' ? '旋转吸附' : 'Rotation Snap'}
|
2025-11-23 14:49:37 +08:00
|
|
|
|
>
|
2025-11-29 23:00:48 +08:00
|
|
|
|
<RotateCw size={12} />
|
|
|
|
|
|
<span>{rotationSnapValue}°</span>
|
2025-11-23 21:45:10 +08:00
|
|
|
|
<ChevronDown size={10} />
|
2025-11-23 14:49:37 +08:00
|
|
|
|
</button>
|
2025-11-29 23:00:48 +08:00
|
|
|
|
{showRotationSnapMenu && (
|
|
|
|
|
|
<div className="viewport-snap-menu">
|
|
|
|
|
|
{rotationSnapOptions.map((val) => (
|
|
|
|
|
|
<button
|
|
|
|
|
|
key={val}
|
|
|
|
|
|
className={rotationSnapValue === val ? 'active' : ''}
|
|
|
|
|
|
onClick={() => { setRotationSnapValue(val); setShowRotationSnapMenu(false); }}
|
|
|
|
|
|
>
|
|
|
|
|
|
{val}°
|
|
|
|
|
|
</button>
|
|
|
|
|
|
))}
|
2025-11-23 14:49:37 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
2025-11-29 23:00:48 +08:00
|
|
|
|
|
|
|
|
|
|
{/* Scale Snap Value */}
|
|
|
|
|
|
<div className="viewport-snap-dropdown" ref={scaleSnapMenuRef}>
|
2025-11-23 21:45:10 +08:00
|
|
|
|
<button
|
2025-11-29 23:00:48 +08:00
|
|
|
|
className="viewport-snap-btn"
|
|
|
|
|
|
onClick={() => { closeAllSnapMenus(); setShowScaleSnapMenu(!showScaleSnapMenu); }}
|
|
|
|
|
|
title={locale === 'zh' ? '缩放吸附' : 'Scale Snap'}
|
2025-11-23 21:45:10 +08:00
|
|
|
|
>
|
2025-11-29 23:00:48 +08:00
|
|
|
|
<Scaling size={12} />
|
|
|
|
|
|
<span>{scaleSnapValue}</span>
|
|
|
|
|
|
<ChevronDown size={10} />
|
2025-11-23 21:45:10 +08:00
|
|
|
|
</button>
|
2025-11-29 23:00:48 +08:00
|
|
|
|
{showScaleSnapMenu && (
|
|
|
|
|
|
<div className="viewport-snap-menu">
|
|
|
|
|
|
{scaleSnapOptions.map((val) => (
|
|
|
|
|
|
<button
|
|
|
|
|
|
key={val}
|
|
|
|
|
|
className={scaleSnapValue === val ? 'active' : ''}
|
|
|
|
|
|
onClick={() => { setScaleSnapValue(val); setShowScaleSnapMenu(false); }}
|
|
|
|
|
|
>
|
|
|
|
|
|
{val}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2025-11-23 21:45:10 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2025-11-29 23:00:48 +08:00
|
|
|
|
|
|
|
|
|
|
<div className="viewport-internal-toolbar-right">
|
|
|
|
|
|
{/* View options */}
|
2025-11-02 23:50:41 +08:00
|
|
|
|
<button
|
2025-11-29 23:00:48 +08:00
|
|
|
|
className={`viewport-btn ${showGrid ? 'active' : ''}`}
|
|
|
|
|
|
onClick={() => setShowGrid(!showGrid)}
|
|
|
|
|
|
title={locale === 'zh' ? '显示网格' : 'Show Grid'}
|
2025-11-02 23:50:41 +08:00
|
|
|
|
>
|
2025-11-29 23:00:48 +08:00
|
|
|
|
<Grid3x3 size={14} />
|
2025-11-02 23:50:41 +08:00
|
|
|
|
</button>
|
2025-11-29 23:00:48 +08:00
|
|
|
|
<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 */}
|
2025-11-02 23:50:41 +08:00
|
|
|
|
<button
|
|
|
|
|
|
className={`viewport-btn ${showStats ? 'active' : ''}`}
|
|
|
|
|
|
onClick={() => setShowStats(!showStats)}
|
2025-11-29 23:00:48 +08:00
|
|
|
|
title={locale === 'zh' ? '统计信息' : 'Stats'}
|
2025-11-02 23:50:41 +08:00
|
|
|
|
>
|
2025-11-23 21:45:10 +08:00
|
|
|
|
<Activity size={14} />
|
2025-11-02 23:50:41 +08:00
|
|
|
|
</button>
|
2025-11-29 23:00:48 +08:00
|
|
|
|
|
|
|
|
|
|
{/* Reset view */}
|
|
|
|
|
|
<button
|
|
|
|
|
|
className="viewport-btn"
|
|
|
|
|
|
onClick={handleReset}
|
|
|
|
|
|
title={locale === 'zh' ? '重置视图' : 'Reset View'}
|
|
|
|
|
|
>
|
|
|
|
|
|
<RotateCcw size={14} />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Fullscreen */}
|
2025-11-02 23:50:41 +08:00
|
|
|
|
<button
|
|
|
|
|
|
className="viewport-btn"
|
|
|
|
|
|
onClick={handleFullscreen}
|
|
|
|
|
|
title={locale === 'zh' ? '全屏' : 'Fullscreen'}
|
|
|
|
|
|
>
|
2025-11-23 21:45:10 +08:00
|
|
|
|
<Maximize2 size={14} />
|
2025-11-02 23:50:41 +08:00
|
|
|
|
</button>
|
2025-11-29 23:00:48 +08:00
|
|
|
|
|
|
|
|
|
|
<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>
|
2025-11-02 23:50:41 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2025-11-29 23:00:48 +08:00
|
|
|
|
|
2025-11-21 10:03:18 +08:00
|
|
|
|
<canvas ref={canvasRef} id="viewport-canvas" className="viewport-canvas" />
|
2025-11-29 23:00:48 +08:00
|
|
|
|
|
2025-11-02 23:50:41 +08:00
|
|
|
|
{showStats && (
|
|
|
|
|
|
<div className="viewport-stats">
|
|
|
|
|
|
<div className="viewport-stat">
|
|
|
|
|
|
<span className="viewport-stat-label">FPS:</span>
|
2025-11-23 14:49:37 +08:00
|
|
|
|
<span className="viewport-stat-value">{engine.state.fps}</span>
|
2025-11-02 23:50:41 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<div className="viewport-stat">
|
|
|
|
|
|
<span className="viewport-stat-label">Draw Calls:</span>
|
2025-11-23 14:49:37 +08:00
|
|
|
|
<span className="viewport-stat-value">{engine.state.drawCalls}</span>
|
2025-11-02 23:50:41 +08:00
|
|
|
|
</div>
|
2025-11-23 14:49:37 +08:00
|
|
|
|
<div className="viewport-stat">
|
|
|
|
|
|
<span className="viewport-stat-label">Sprites:</span>
|
|
|
|
|
|
<span className="viewport-stat-value">{engine.state.spriteCount}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{engine.state.error && (
|
2025-11-21 10:03:18 +08:00
|
|
|
|
<div className="viewport-stat viewport-stat-error">
|
|
|
|
|
|
<span className="viewport-stat-label">Error:</span>
|
|
|
|
|
|
<span className="viewport-stat-value">{engine.state.error}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2025-11-02 23:50:41 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2025-11-23 14:49:37 +08:00
|
|
|
|
|
|
|
|
|
|
<QRCodeDialog
|
|
|
|
|
|
url={devicePreviewUrl}
|
|
|
|
|
|
isOpen={showQRDialog}
|
|
|
|
|
|
onClose={() => setShowQRDialog(false)}
|
|
|
|
|
|
/>
|
2025-10-15 17:28:45 +08:00
|
|
|
|
</div>
|
2025-11-02 23:50:41 +08:00
|
|
|
|
);
|
2025-10-15 17:28:45 +08:00
|
|
|
|
}
|