feat(3d): FBX/GLTF/OBJ 加载器与骨骼动画支持 (#315)
* feat(3d): FBX/GLTF/OBJ 加载器与骨骼动画支持 * chore: 更新 pnpm-lock.yaml * fix: 移除未使用的变量和方法 * fix: 修复 mesh-3d-editor tsconfig 引用路径 * fix: 修复正则表达式 ReDoS 漏洞
This commit is contained in:
@@ -32,6 +32,8 @@
|
||||
"@esengine/engine-core": "workspace:*",
|
||||
"@esengine/material-editor": "workspace:*",
|
||||
"@esengine/material-system": "workspace:*",
|
||||
"@esengine/mesh-3d": "workspace:*",
|
||||
"@esengine/mesh-3d-editor": "workspace:*",
|
||||
"@esengine/particle": "workspace:*",
|
||||
"@esengine/particle-editor": "workspace:*",
|
||||
"@esengine/physics-rapier2d": "workspace:*",
|
||||
|
||||
@@ -75,12 +75,30 @@ export class TauriAPI {
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取文件内容
|
||||
* 读取文件内容(文本)
|
||||
*/
|
||||
static async readFileContent(path: string): Promise<string> {
|
||||
return await invoke<string>('read_file_content', { path });
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取文件内容(二进制)
|
||||
* Read file content as binary ArrayBuffer
|
||||
*/
|
||||
static async readFileBinary(path: string): Promise<ArrayBuffer> {
|
||||
// Use Tauri read_file_as_base64 command which returns base64 encoded data
|
||||
// 使用 Tauri 的 read_file_as_base64 命令,返回 base64 编码的数据
|
||||
const base64: string = await invoke<string>('read_file_as_base64', { filePath: path });
|
||||
// Decode base64 to ArrayBuffer
|
||||
// 将 base64 解码为 ArrayBuffer
|
||||
const binaryString = atob(base64);
|
||||
const bytes = new Uint8Array(binaryString.length);
|
||||
for (let i = 0; i < binaryString.length; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i);
|
||||
}
|
||||
return bytes.buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出目录内容
|
||||
*/
|
||||
|
||||
@@ -27,6 +27,7 @@ import { BlueprintPlugin } from '@esengine/blueprint-editor';
|
||||
import { MaterialPlugin } from '@esengine/material-editor';
|
||||
import { SpritePlugin } from '@esengine/sprite-editor';
|
||||
import { ShaderEditorPlugin } from '@esengine/shader-editor';
|
||||
import { Mesh3DPlugin } from '@esengine/mesh-3d-editor';
|
||||
|
||||
// 纯运行时插件 | Runtime-only plugins
|
||||
import { CameraPlugin } from '@esengine/camera';
|
||||
@@ -70,6 +71,7 @@ export class PluginInstaller {
|
||||
{ name: 'BlueprintPlugin', plugin: BlueprintPlugin },
|
||||
{ name: 'MaterialPlugin', plugin: MaterialPlugin },
|
||||
{ name: 'ShaderEditorPlugin', plugin: ShaderEditorPlugin },
|
||||
{ name: 'Mesh3DPlugin', plugin: Mesh3DPlugin },
|
||||
];
|
||||
|
||||
for (const { name, plugin } of modulePlugins) {
|
||||
|
||||
@@ -41,10 +41,17 @@ import {
|
||||
AlertTriangle,
|
||||
X,
|
||||
FolderPlus,
|
||||
Inbox
|
||||
Inbox,
|
||||
Box,
|
||||
Bone,
|
||||
Film,
|
||||
Palette,
|
||||
Loader2
|
||||
} from 'lucide-react';
|
||||
import { Core, Entity, HierarchySystem, PrefabSerializer } from '@esengine/ecs-framework';
|
||||
import { MessageHub, FileActionRegistry, AssetRegistryService, MANAGED_ASSET_DIRECTORIES, type FileCreationTemplate, EntityStoreService, SceneManagerService } from '@esengine/editor-core';
|
||||
import type { IGLTFAsset, IMeshData, IGLTFMaterial, IGLTFAnimationClip, IAssetContent, IAssetParseContext } from '@esengine/asset-system';
|
||||
import { FBXLoader, GLTFLoader } from '@esengine/asset-system';
|
||||
import { TauriAPI, DirectoryEntry } from '../api/tauri';
|
||||
import { SettingsService } from '../services/SettingsService';
|
||||
import { ContextMenu, ContextMenuItem } from './ContextMenu';
|
||||
@@ -54,10 +61,25 @@ import '../styles/ContentBrowser.css';
|
||||
interface AssetItem {
|
||||
name: string;
|
||||
path: string;
|
||||
type: 'file' | 'folder';
|
||||
type: 'file' | 'folder' | 'sub-asset';
|
||||
extension?: string;
|
||||
size?: number;
|
||||
modified?: number;
|
||||
// Sub-asset specific fields
|
||||
// 子资产特定字段
|
||||
parentPath?: string; // Path to parent model file | 父模型文件路径
|
||||
subAssetType?: 'mesh' | 'material' | 'animation' | 'skeleton';
|
||||
subAssetIndex?: number; // Index within parent asset | 在父资产中的索引
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if file extension is an expandable 3D model
|
||||
* 检查文件扩展名是否是可展开的3D模型
|
||||
*/
|
||||
function isExpandableModel(extension: string | undefined): boolean {
|
||||
if (!extension) return false;
|
||||
const ext = extension.toLowerCase();
|
||||
return ['fbx', 'gltf', 'glb', 'obj'].includes(ext);
|
||||
}
|
||||
|
||||
interface FolderNode {
|
||||
@@ -159,6 +181,17 @@ function highlightSearchText(text: string, query: string): React.ReactNode {
|
||||
function getAssetTypeName(asset: AssetItem): string {
|
||||
if (asset.type === 'folder') return 'Folder';
|
||||
|
||||
// Handle sub-assets | 处理子资产
|
||||
if (asset.type === 'sub-asset') {
|
||||
switch (asset.subAssetType) {
|
||||
case 'mesh': return 'Mesh';
|
||||
case 'material': return 'Material';
|
||||
case 'animation': return 'Animation';
|
||||
case 'skeleton': return 'Skeleton';
|
||||
default: return 'Sub-Asset';
|
||||
}
|
||||
}
|
||||
|
||||
// Check for compound extensions first
|
||||
const name = asset.name.toLowerCase();
|
||||
if (name.endsWith('.tilemap.json') || name.endsWith('.tilemap')) return 'Tilemap';
|
||||
@@ -180,6 +213,10 @@ function getAssetTypeName(asset: AssetItem): string {
|
||||
case 'prefab': return 'Prefab';
|
||||
case 'mat': return 'Material';
|
||||
case 'anim': return 'Animation';
|
||||
case 'fbx':
|
||||
case 'gltf':
|
||||
case 'glb':
|
||||
case 'obj': return '3D Model';
|
||||
default: return ext?.toUpperCase() || 'File';
|
||||
}
|
||||
}
|
||||
@@ -251,6 +288,12 @@ export function ContentBrowser({
|
||||
// Drag and drop state for file moving
|
||||
const [dragOverFolder, setDragOverFolder] = useState<string | null>(null);
|
||||
|
||||
// Expanded model assets (for viewing sub-assets)
|
||||
// 展开的模型资产(用于查看子资产)
|
||||
const [expandedModels, setExpandedModels] = useState<Set<string>>(new Set());
|
||||
const [modelSubAssets, setModelSubAssets] = useState<Map<string, AssetItem[]>>(new Map());
|
||||
const [loadingModels, setLoadingModels] = useState<Set<string>>(new Set());
|
||||
|
||||
// 初始化和监听插件安装事件以更新模板列表
|
||||
// Initialize and listen for plugin installation events to update template list
|
||||
useEffect(() => {
|
||||
@@ -637,6 +680,172 @@ export class ${className} {
|
||||
}
|
||||
}, [currentPath, projectPath, loadAssets, buildFolderTree]);
|
||||
|
||||
/**
|
||||
* Load sub-assets from a 3D model file
|
||||
* 从3D模型文件加载子资产
|
||||
*/
|
||||
const loadModelSubAssets = useCallback(async (modelPath: string): Promise<AssetItem[]> => {
|
||||
try {
|
||||
const modelName = modelPath.split(/[\\/]/).pop() || 'Model';
|
||||
const ext = modelName.split('.').pop()?.toLowerCase() || '';
|
||||
|
||||
// Read file binary content
|
||||
// 读取文件二进制内容
|
||||
const binaryData = await TauriAPI.readFileBinary(modelPath);
|
||||
if (!binaryData || binaryData.byteLength === 0) {
|
||||
console.warn('[ContentBrowser] Cannot read file:', modelPath);
|
||||
return [];
|
||||
}
|
||||
|
||||
// Create minimal parse context (loaders don't need full metadata for basic parsing)
|
||||
// 创建最小解析上下文(加载器只需要基本解析的路径信息)
|
||||
const parseContext = {
|
||||
metadata: {
|
||||
path: modelPath,
|
||||
name: modelName,
|
||||
type: ext === 'fbx' ? 'model/fbx' : 'model/gltf',
|
||||
guid: '',
|
||||
size: binaryData.byteLength,
|
||||
hash: '',
|
||||
dependencies: [],
|
||||
lastModified: Date.now(),
|
||||
importerVersion: '1.0.0',
|
||||
labels: [],
|
||||
tags: [],
|
||||
version: 1
|
||||
},
|
||||
loadDependency: async () => null
|
||||
} as unknown as IAssetParseContext;
|
||||
|
||||
// Create content object
|
||||
// 创建内容对象
|
||||
const content: IAssetContent = {
|
||||
type: 'binary',
|
||||
binary: binaryData
|
||||
};
|
||||
|
||||
// Select appropriate loader and parse
|
||||
// 选择合适的加载器并解析
|
||||
let asset: IGLTFAsset;
|
||||
if (ext === 'fbx') {
|
||||
const loader = new FBXLoader();
|
||||
asset = await loader.parse(content, parseContext);
|
||||
} else if (ext === 'gltf' || ext === 'glb') {
|
||||
const loader = new GLTFLoader();
|
||||
asset = await loader.parse(content, parseContext);
|
||||
} else {
|
||||
console.warn('[ContentBrowser] Unsupported model format:', ext);
|
||||
return [];
|
||||
}
|
||||
|
||||
const subAssets: AssetItem[] = [];
|
||||
|
||||
// Add meshes
|
||||
// 添加网格
|
||||
if (asset.meshes && asset.meshes.length > 0) {
|
||||
asset.meshes.forEach((mesh: IMeshData, index: number) => {
|
||||
subAssets.push({
|
||||
name: mesh.name || `Mesh_${index}`,
|
||||
path: `${modelPath}#mesh:${index}`,
|
||||
type: 'sub-asset',
|
||||
parentPath: modelPath,
|
||||
subAssetType: 'mesh',
|
||||
subAssetIndex: index
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Add materials
|
||||
// 添加材质
|
||||
if (asset.materials && asset.materials.length > 0) {
|
||||
asset.materials.forEach((material: IGLTFMaterial, index: number) => {
|
||||
subAssets.push({
|
||||
name: material.name || `Material_${index}`,
|
||||
path: `${modelPath}#material:${index}`,
|
||||
type: 'sub-asset',
|
||||
parentPath: modelPath,
|
||||
subAssetType: 'material',
|
||||
subAssetIndex: index
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Add animations
|
||||
// 添加动画
|
||||
if (asset.animations && asset.animations.length > 0) {
|
||||
asset.animations.forEach((anim: IGLTFAnimationClip, index: number) => {
|
||||
subAssets.push({
|
||||
name: anim.name || `Animation_${index}`,
|
||||
path: `${modelPath}#animation:${index}`,
|
||||
type: 'sub-asset',
|
||||
parentPath: modelPath,
|
||||
subAssetType: 'animation',
|
||||
subAssetIndex: index
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Add skeleton if present
|
||||
// 添加骨骼(如果存在)
|
||||
if (asset.skeleton) {
|
||||
subAssets.push({
|
||||
name: `Skeleton (${asset.skeleton.joints.length} joints)`,
|
||||
path: `${modelPath}#skeleton:0`,
|
||||
type: 'sub-asset',
|
||||
parentPath: modelPath,
|
||||
subAssetType: 'skeleton',
|
||||
subAssetIndex: 0
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`[ContentBrowser] Loaded sub-assets for ${modelName}:`);
|
||||
console.log(` - Meshes: ${asset.meshes?.length ?? 0}`);
|
||||
console.log(` - Materials: ${asset.materials?.length ?? 0}`);
|
||||
console.log(` - Animations: ${asset.animations?.length ?? 0}`);
|
||||
console.log(` - Skeleton: ${asset.skeleton ? 'yes' : 'no'}`);
|
||||
console.log(` - Sub-assets total: ${subAssets.length}`);
|
||||
return subAssets;
|
||||
} catch (error) {
|
||||
console.error('[ContentBrowser] Failed to load model sub-assets:', error);
|
||||
return [];
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Toggle model expansion
|
||||
* 切换模型展开状态
|
||||
*/
|
||||
const toggleModelExpand = useCallback(async (modelPath: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
|
||||
const isExpanded = expandedModels.has(modelPath);
|
||||
|
||||
if (isExpanded) {
|
||||
// Collapse
|
||||
// 折叠
|
||||
setExpandedModels(prev => {
|
||||
const next = new Set(prev);
|
||||
next.delete(modelPath);
|
||||
return next;
|
||||
});
|
||||
} else {
|
||||
// Expand - load sub-assets if not already loaded
|
||||
// 展开 - 如果尚未加载则加载子资产
|
||||
if (!modelSubAssets.has(modelPath)) {
|
||||
setLoadingModels(prev => new Set(prev).add(modelPath));
|
||||
const subAssets = await loadModelSubAssets(modelPath);
|
||||
setModelSubAssets(prev => new Map(prev).set(modelPath, subAssets));
|
||||
setLoadingModels(prev => {
|
||||
const next = new Set(prev);
|
||||
next.delete(modelPath);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
setExpandedModels(prev => new Set(prev).add(modelPath));
|
||||
}
|
||||
}, [expandedModels, modelSubAssets, loadModelSubAssets]);
|
||||
|
||||
// 点击外部关闭过滤器下拉菜单 | Close filter dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
if (!showFilterDropdown) return;
|
||||
@@ -1031,6 +1240,21 @@ export class ${className} {
|
||||
setCurrentPath(asset.path);
|
||||
loadAssets(asset.path);
|
||||
setExpandedFolders(prev => new Set([...prev, asset.path]));
|
||||
} else if (asset.type === 'sub-asset') {
|
||||
// Handle sub-asset double click
|
||||
// 处理子资产双击
|
||||
if (asset.subAssetType === 'animation' && asset.parentPath) {
|
||||
// Open animation preview panel
|
||||
// 打开动画预览面板
|
||||
messageHub?.publish('animation:preview', {
|
||||
filePath: asset.parentPath,
|
||||
animationIndex: asset.subAssetIndex ?? 0
|
||||
});
|
||||
console.log('[ContentBrowser] Opening animation preview:', asset.parentPath, 'index:', asset.subAssetIndex);
|
||||
}
|
||||
// Other sub-asset types can be handled here
|
||||
// 其他子资产类型可以在这里处理
|
||||
return;
|
||||
} else {
|
||||
const ext = asset.extension?.toLowerCase();
|
||||
console.log('[ContentBrowser] File ext:', ext, 'onOpenScene:', !!onOpenScene);
|
||||
@@ -1088,7 +1312,7 @@ export class ${className} {
|
||||
console.error('Failed to open file:', error);
|
||||
}
|
||||
}
|
||||
}, [loadAssets, onOpenScene, fileActionRegistry, projectPath]);
|
||||
}, [loadAssets, onOpenScene, fileActionRegistry, projectPath, messageHub]);
|
||||
|
||||
// Handle context menu
|
||||
const handleContextMenu = useCallback((e: React.MouseEvent, asset?: AssetItem) => {
|
||||
@@ -1194,6 +1418,23 @@ export class ${className} {
|
||||
return <Folder size={size} className="asset-thumbnail-icon folder" />;
|
||||
}
|
||||
|
||||
// Handle sub-assets
|
||||
// 处理子资产
|
||||
if (asset.type === 'sub-asset') {
|
||||
switch (asset.subAssetType) {
|
||||
case 'mesh':
|
||||
return <Box size={size} className="asset-thumbnail-icon sub-asset mesh" />;
|
||||
case 'material':
|
||||
return <Palette size={size} className="asset-thumbnail-icon sub-asset material" />;
|
||||
case 'animation':
|
||||
return <Film size={size} className="asset-thumbnail-icon sub-asset animation" />;
|
||||
case 'skeleton':
|
||||
return <Bone size={size} className="asset-thumbnail-icon sub-asset skeleton" />;
|
||||
default:
|
||||
return <File size={size} className="asset-thumbnail-icon sub-asset" />;
|
||||
}
|
||||
}
|
||||
|
||||
const ext = asset.extension?.toLowerCase();
|
||||
switch (ext) {
|
||||
case 'ecs':
|
||||
@@ -1213,6 +1454,13 @@ export class ${className} {
|
||||
case 'gif':
|
||||
case 'webp':
|
||||
return <FileImage size={size} className="asset-thumbnail-icon image" />;
|
||||
// 3D Model files | 3D 模型文件
|
||||
case 'fbx':
|
||||
case 'obj':
|
||||
case 'gltf':
|
||||
case 'glb':
|
||||
case 'dae':
|
||||
return <Box size={size} className="asset-thumbnail-icon model3d" />;
|
||||
default:
|
||||
return <File size={size} className="asset-thumbnail-icon" />;
|
||||
}
|
||||
@@ -1698,8 +1946,8 @@ export class ${className} {
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Filter assets by search and hidden extensions
|
||||
// 按搜索词和隐藏扩展名过滤资产
|
||||
// Filter assets by search and hidden extensions, and inject sub-assets for expanded models
|
||||
// 按搜索词和隐藏扩展名过滤资产,并为展开的模型注入子资产
|
||||
const filteredAssets = useMemo(() => {
|
||||
let result = assets;
|
||||
|
||||
@@ -1717,8 +1965,24 @@ export class ${className} {
|
||||
result = result.filter(a => a.name.toLowerCase().includes(searchQuery.toLowerCase()));
|
||||
}
|
||||
|
||||
// Inject sub-assets for expanded models
|
||||
// 为展开的模型注入子资产
|
||||
if (expandedModels.size > 0) {
|
||||
const resultWithSubAssets: AssetItem[] = [];
|
||||
for (const asset of result) {
|
||||
resultWithSubAssets.push(asset);
|
||||
// If this is an expanded model, add its sub-assets after it
|
||||
// 如果这是一个展开的模型,在其后添加子资产
|
||||
if (asset.type === 'file' && isExpandableModel(asset.extension) && expandedModels.has(asset.path)) {
|
||||
const subAssets = modelSubAssets.get(asset.path) || [];
|
||||
resultWithSubAssets.push(...subAssets);
|
||||
}
|
||||
}
|
||||
result = resultWithSubAssets;
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [assets, hiddenExtensions, searchQuery]);
|
||||
}, [assets, hiddenExtensions, searchQuery, expandedModels, modelSubAssets]);
|
||||
|
||||
const breadcrumbs = getBreadcrumbs();
|
||||
|
||||
@@ -1994,18 +2258,26 @@ export class ${className} {
|
||||
) : (
|
||||
filteredAssets.map(asset => {
|
||||
const isDragOverAsset = asset.type === 'folder' && dragOverFolder === asset.path;
|
||||
const isSubAsset = asset.type === 'sub-asset';
|
||||
const isExpandableFile = asset.type === 'file' && isExpandableModel(asset.extension);
|
||||
const isModelExpanded = isExpandableFile && expandedModels.has(asset.path);
|
||||
const isModelLoading = isExpandableFile && loadingModels.has(asset.path);
|
||||
return (
|
||||
<div
|
||||
key={asset.path}
|
||||
className={`cb-asset-item ${selectedPaths.has(asset.path) ? 'selected' : ''} ${isDragOverAsset ? 'drag-over' : ''}`}
|
||||
className={`cb-asset-item ${selectedPaths.has(asset.path) ? 'selected' : ''} ${isDragOverAsset ? 'drag-over' : ''} ${isSubAsset ? 'sub-asset' : ''} ${isModelExpanded ? 'expanded' : ''}`}
|
||||
onClick={(e) => handleAssetClick(asset, e)}
|
||||
onDoubleClick={() => handleAssetDoubleClick(asset)}
|
||||
onContextMenu={(e) => {
|
||||
e.stopPropagation();
|
||||
handleContextMenu(e, asset);
|
||||
}}
|
||||
draggable
|
||||
draggable={!isSubAsset}
|
||||
onDragStart={(e) => {
|
||||
if (isSubAsset) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
e.dataTransfer.setData('asset-path', asset.path);
|
||||
e.dataTransfer.setData('text/plain', asset.path);
|
||||
// Add GUID for files
|
||||
@@ -2038,6 +2310,22 @@ export class ${className} {
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Expand button for 3D models | 3D模型的展开按钮 */}
|
||||
{isExpandableFile && (
|
||||
<button
|
||||
className={`cb-asset-expand-btn ${isModelExpanded ? 'expanded' : ''}`}
|
||||
onClick={(e) => toggleModelExpand(asset.path, e)}
|
||||
title={isModelExpanded ? 'Collapse' : 'Expand'}
|
||||
>
|
||||
{isModelLoading ? (
|
||||
<Loader2 size={12} className="spinning" />
|
||||
) : isModelExpanded ? (
|
||||
<ChevronDown size={12} />
|
||||
) : (
|
||||
<ChevronRight size={12} />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<div className="cb-asset-thumbnail">
|
||||
{getFileIcon(asset)}
|
||||
</div>
|
||||
|
||||
@@ -23,6 +23,7 @@ import { QRCodeDialog } from './QRCodeDialog';
|
||||
import { collectAssetReferences } from '@esengine/asset-system';
|
||||
import { RuntimeSceneManager, type IRuntimeSceneManager } from '@esengine/runtime-core';
|
||||
import { ParticleSystemComponent } from '@esengine/particle';
|
||||
import { MeshComponent } from '@esengine/mesh-3d';
|
||||
|
||||
import type { ModuleManifest } from '../services/RuntimeResolver';
|
||||
|
||||
@@ -314,6 +315,12 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport
|
||||
const orbitCameraRef = useRef(orbitCamera);
|
||||
const isOrbitingRef = useRef(false);
|
||||
const isPanningRef = useRef(false);
|
||||
const isZoomingRef = useRef(false);
|
||||
// Fly mode (right-click + WASD) | 飞行模式(右键 + WASD)
|
||||
const isFlyModeRef = useRef(false);
|
||||
const flyKeysRef = useRef({ w: false, a: false, s: false, d: false, q: false, e: false });
|
||||
const flySpeedRef = useRef(10); // units per second | 每秒单位数
|
||||
const altKeyRef = useRef(false);
|
||||
const selectedEntityRef = useRef<Entity | null>(null);
|
||||
const messageHubRef = useRef<MessageHub | null>(null);
|
||||
const commandManagerRef = useRef<CommandManager | null>(null);
|
||||
@@ -490,23 +497,43 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport
|
||||
return;
|
||||
}
|
||||
|
||||
// 3D mode: orbit camera controls
|
||||
// 3D 模式:轨道相机控制
|
||||
// 3D mode: Scene view camera controls
|
||||
// 3D 模式:场景视图相机控制
|
||||
// - Alt + Left: Orbit | Alt + 左键:轨道旋转
|
||||
// - Alt + Middle / Middle: Pan | Alt + 中键 / 中键:平移
|
||||
// - Alt + Right: Zoom | Alt + 右键:缩放
|
||||
// - Right: Fly mode (+ WASD) | 右键:飞行模式(+ WASD)
|
||||
if (renderModeRef.current === '3D') {
|
||||
if (e.button === 0) {
|
||||
// Left button: orbit (rotate around target)
|
||||
// 左键:轨道旋转(围绕目标旋转)
|
||||
const isAlt = e.altKey;
|
||||
|
||||
if (e.button === 0 && isAlt) {
|
||||
// Alt + Left button: orbit (rotate around target)
|
||||
// Alt + 左键:轨道旋转(围绕目标旋转)
|
||||
isOrbitingRef.current = true;
|
||||
lastMousePosRef.current = { x: e.clientX, y: e.clientY };
|
||||
canvas.style.cursor = 'grabbing';
|
||||
e.preventDefault();
|
||||
} else if (e.button === 1 || e.button === 2) {
|
||||
// Middle/Right button: pan
|
||||
// 中键/右键:平移
|
||||
} else if (e.button === 1 || (e.button === 1 && isAlt)) {
|
||||
// Middle button (with or without Alt): pan
|
||||
// 中键(有无 Alt):平移
|
||||
isPanningRef.current = true;
|
||||
lastMousePosRef.current = { x: e.clientX, y: e.clientY };
|
||||
canvas.style.cursor = 'move';
|
||||
e.preventDefault();
|
||||
} else if (e.button === 2 && isAlt) {
|
||||
// Alt + Right button: zoom
|
||||
// Alt + 右键:缩放
|
||||
isZoomingRef.current = true;
|
||||
lastMousePosRef.current = { x: e.clientX, y: e.clientY };
|
||||
canvas.style.cursor = 'ns-resize';
|
||||
e.preventDefault();
|
||||
} else if (e.button === 2 && !isAlt) {
|
||||
// Right button (without Alt): fly mode
|
||||
// 右键(无 Alt):飞行模式
|
||||
isFlyModeRef.current = true;
|
||||
lastMousePosRef.current = { x: e.clientX, y: e.clientY };
|
||||
canvas.style.cursor = 'crosshair';
|
||||
e.preventDefault();
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -608,8 +635,8 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport
|
||||
const deltaX = e.clientX - lastMousePosRef.current.x;
|
||||
const deltaY = e.clientY - lastMousePosRef.current.y;
|
||||
|
||||
// 3D mode: orbit camera controls
|
||||
// 3D 模式:轨道相机控制
|
||||
// 3D mode: Scene view camera controls
|
||||
// 3D 模式:场景视图相机控制
|
||||
if (renderModeRef.current === '3D') {
|
||||
if (isOrbitingRef.current) {
|
||||
// Orbit: rotate around target
|
||||
@@ -619,31 +646,76 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport
|
||||
const newYaw = prev.yaw + deltaX * orbitSensitivity;
|
||||
const newPitch = Math.max(-89, Math.min(89, prev.pitch - deltaY * orbitSensitivity));
|
||||
const newOrbit = { ...prev, yaw: newYaw, pitch: newPitch };
|
||||
// Sync to engine in next tick
|
||||
// 在下一帧同步到引擎
|
||||
requestAnimationFrame(() => syncOrbitCameraToEngine(newOrbit));
|
||||
return newOrbit;
|
||||
});
|
||||
lastMousePosRef.current = { x: e.clientX, y: e.clientY };
|
||||
} else if (isPanningRef.current) {
|
||||
// Pan: move target point
|
||||
// 平移:移动目标点
|
||||
// Pan: move target point (drag to move scene in same direction)
|
||||
// 平移:移动目标点(拖拽方向与场景移动方向相同)
|
||||
const panSensitivity = 0.01;
|
||||
const orbit = orbitCameraRef.current;
|
||||
const yawRad = (orbit.yaw * Math.PI) / 180;
|
||||
const pitchRad = (orbit.pitch * Math.PI) / 180;
|
||||
|
||||
// Calculate pan direction based on camera orientation
|
||||
// 根据相机朝向计算平移方向
|
||||
// Calculate camera right and up vectors
|
||||
// 计算相机的右向量和上向量
|
||||
const rightX = Math.cos(yawRad);
|
||||
const rightZ = -Math.sin(yawRad);
|
||||
// Up vector in world space (simplified)
|
||||
const upY = Math.cos(pitchRad);
|
||||
|
||||
setOrbitCamera((prev) => {
|
||||
const panScale = prev.distance * panSensitivity;
|
||||
// 左右反转,上下保持原样
|
||||
// Invert horizontal, keep vertical as is
|
||||
const newOrbit = {
|
||||
...prev,
|
||||
targetX: prev.targetX - deltaX * rightX * panScale,
|
||||
targetY: prev.targetY + deltaY * panScale,
|
||||
targetZ: prev.targetZ - deltaX * rightZ * panScale
|
||||
targetX: prev.targetX + deltaX * rightX * panScale,
|
||||
targetY: prev.targetY + deltaY * upY * panScale,
|
||||
targetZ: prev.targetZ + deltaX * rightZ * panScale
|
||||
};
|
||||
requestAnimationFrame(() => syncOrbitCameraToEngine(newOrbit));
|
||||
return newOrbit;
|
||||
});
|
||||
lastMousePosRef.current = { x: e.clientX, y: e.clientY };
|
||||
} else if (isZoomingRef.current) {
|
||||
// Alt + Right: Zoom by moving distance
|
||||
// Alt + 右键:通过改变距离来缩放
|
||||
const zoomSensitivity = 0.02;
|
||||
setOrbitCamera((prev) => {
|
||||
const newDistance = Math.max(0.5, Math.min(1000, prev.distance * (1 + deltaY * zoomSensitivity)));
|
||||
const newOrbit = { ...prev, distance: newDistance };
|
||||
requestAnimationFrame(() => syncOrbitCameraToEngine(newOrbit));
|
||||
return newOrbit;
|
||||
});
|
||||
lastMousePosRef.current = { x: e.clientX, y: e.clientY };
|
||||
} else if (isFlyModeRef.current) {
|
||||
// Fly mode: first-person camera look (like FPS)
|
||||
// 飞行模式:第一人称相机视角(类似 FPS)
|
||||
const lookSensitivity = 0.2;
|
||||
setOrbitCamera((prev) => {
|
||||
const newYaw = prev.yaw + deltaX * lookSensitivity;
|
||||
const newPitch = Math.max(-89, Math.min(89, prev.pitch - deltaY * lookSensitivity));
|
||||
// In fly mode, target moves with camera to maintain forward direction
|
||||
// 在飞行模式下,目标点随相机移动以保持前进方向
|
||||
const pos = calculateOrbitCameraPosition({ ...prev, yaw: newYaw, pitch: newPitch });
|
||||
|
||||
// Calculate new target based on camera looking forward
|
||||
// 根据相机前方计算新目标点
|
||||
const pitchRad = (newPitch * Math.PI) / 180;
|
||||
const yawRad = (newYaw * Math.PI) / 180;
|
||||
const forwardX = -Math.cos(pitchRad) * Math.sin(yawRad);
|
||||
const forwardY = Math.sin(pitchRad);
|
||||
const forwardZ = -Math.cos(pitchRad) * Math.cos(yawRad);
|
||||
|
||||
const newOrbit = {
|
||||
...prev,
|
||||
yaw: newYaw,
|
||||
pitch: newPitch,
|
||||
targetX: pos.x + forwardX * prev.distance,
|
||||
targetY: pos.y + forwardY * prev.distance,
|
||||
targetZ: pos.z + forwardZ * prev.distance
|
||||
};
|
||||
requestAnimationFrame(() => syncOrbitCameraToEngine(newOrbit));
|
||||
return newOrbit;
|
||||
@@ -749,8 +821,8 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
// 3D mode: reset orbit/pan flags
|
||||
// 3D 模式:重置轨道/平移标志
|
||||
// 3D mode: reset all camera control flags
|
||||
// 3D 模式:重置所有相机控制标志
|
||||
if (isOrbitingRef.current) {
|
||||
isOrbitingRef.current = false;
|
||||
canvas.style.cursor = 'grab';
|
||||
@@ -759,6 +831,16 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport
|
||||
isPanningRef.current = false;
|
||||
canvas.style.cursor = 'grab';
|
||||
}
|
||||
if (isZoomingRef.current) {
|
||||
isZoomingRef.current = false;
|
||||
canvas.style.cursor = 'grab';
|
||||
}
|
||||
if (isFlyModeRef.current) {
|
||||
isFlyModeRef.current = false;
|
||||
canvas.style.cursor = 'grab';
|
||||
// Reset fly keys | 重置飞行按键
|
||||
flyKeysRef.current = { w: false, a: false, s: false, d: false, q: false, e: false };
|
||||
}
|
||||
|
||||
// 2D mode: original mouse up handling
|
||||
// 2D 模式:原始鼠标抬起处理
|
||||
@@ -857,16 +939,146 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport
|
||||
setCamera2DZoom((prev) => Math.max(0.01, Math.min(100, prev * zoomFactor)));
|
||||
};
|
||||
|
||||
// Keyboard event handlers for fly mode and focus
|
||||
// 飞行模式和聚焦的键盘事件处理
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (renderModeRef.current !== '3D') return;
|
||||
if (playStateRef.current === 'playing') return;
|
||||
|
||||
// WASD + QE for fly mode movement
|
||||
// WASD + QE 飞行模式移动
|
||||
const key = e.key.toLowerCase();
|
||||
if (key === 'w' || key === 'a' || key === 's' || key === 'd' || key === 'q' || key === 'e') {
|
||||
flyKeysRef.current[key as keyof typeof flyKeysRef.current] = true;
|
||||
}
|
||||
|
||||
// F key: Focus on selected entity
|
||||
// F 键:聚焦到选中实体
|
||||
if (key === 'f' && selectedEntityRef.current) {
|
||||
const entity = selectedEntityRef.current;
|
||||
const transform = entity.getComponent(TransformComponent);
|
||||
if (transform) {
|
||||
// Focus camera on entity position
|
||||
// 聚焦相机到实体位置
|
||||
setOrbitCamera((prev) => {
|
||||
const newOrbit = {
|
||||
...prev,
|
||||
targetX: transform.position.x,
|
||||
targetY: transform.position.y,
|
||||
targetZ: transform.position.z,
|
||||
distance: Math.max(5, prev.distance) // Ensure minimum distance
|
||||
};
|
||||
requestAnimationFrame(() => syncOrbitCameraToEngine(newOrbit));
|
||||
return newOrbit;
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyUp = (e: KeyboardEvent) => {
|
||||
const key = e.key.toLowerCase();
|
||||
if (key === 'w' || key === 'a' || key === 's' || key === 'd' || key === 'q' || key === 'e') {
|
||||
flyKeysRef.current[key as keyof typeof flyKeysRef.current] = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Fly mode animation loop
|
||||
// 飞行模式动画循环
|
||||
let flyAnimationId: number | null = null;
|
||||
let lastFlyTime = 0;
|
||||
|
||||
const updateFlyMode = (timestamp: number) => {
|
||||
if (!isFlyModeRef.current) {
|
||||
flyAnimationId = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const deltaTime = lastFlyTime > 0 ? (timestamp - lastFlyTime) / 1000 : 0.016;
|
||||
lastFlyTime = timestamp;
|
||||
|
||||
const keys = flyKeysRef.current;
|
||||
const speed = flySpeedRef.current * deltaTime;
|
||||
|
||||
// Check if any movement key is pressed
|
||||
// 检查是否有移动键被按下
|
||||
if (keys.w || keys.a || keys.s || keys.d || keys.q || keys.e) {
|
||||
setOrbitCamera((prev) => {
|
||||
const pitchRad = (prev.pitch * Math.PI) / 180;
|
||||
const yawRad = (prev.yaw * Math.PI) / 180;
|
||||
|
||||
// Calculate camera directions
|
||||
// 计算相机方向
|
||||
const forwardX = -Math.cos(pitchRad) * Math.sin(yawRad);
|
||||
const forwardY = Math.sin(pitchRad);
|
||||
const forwardZ = -Math.cos(pitchRad) * Math.cos(yawRad);
|
||||
const rightX = Math.cos(yawRad);
|
||||
const rightZ = -Math.sin(yawRad);
|
||||
|
||||
let moveX = 0, moveY = 0, moveZ = 0;
|
||||
|
||||
// WASD movement (reversed A/D for intuitive scene navigation)
|
||||
// WASD 移动(反转 A/D 以符合直觉的场景导航)
|
||||
if (keys.w) { moveX += forwardX; moveY += forwardY; moveZ += forwardZ; }
|
||||
if (keys.s) { moveX -= forwardX; moveY -= forwardY; moveZ -= forwardZ; }
|
||||
if (keys.a) { moveX += rightX; moveZ += rightZ; } // Reversed: move scene right
|
||||
if (keys.d) { moveX -= rightX; moveZ -= rightZ; } // Reversed: move scene left
|
||||
// QE for up/down
|
||||
if (keys.e) { moveY += 1; }
|
||||
if (keys.q) { moveY -= 1; }
|
||||
|
||||
// Calculate current camera position
|
||||
const pos = calculateOrbitCameraPosition(prev);
|
||||
|
||||
// Move both camera and target
|
||||
// 同时移动相机和目标点
|
||||
const newOrbit = {
|
||||
...prev,
|
||||
targetX: prev.targetX + moveX * speed,
|
||||
targetY: prev.targetY + moveY * speed,
|
||||
targetZ: prev.targetZ + moveZ * speed
|
||||
};
|
||||
|
||||
requestAnimationFrame(() => syncOrbitCameraToEngine(newOrbit));
|
||||
return newOrbit;
|
||||
});
|
||||
}
|
||||
|
||||
flyAnimationId = requestAnimationFrame(updateFlyMode);
|
||||
};
|
||||
|
||||
// Start fly mode loop when entering fly mode
|
||||
// 进入飞行模式时启动飞行循环
|
||||
const startFlyLoop = () => {
|
||||
if (flyAnimationId === null && isFlyModeRef.current) {
|
||||
lastFlyTime = 0;
|
||||
flyAnimationId = requestAnimationFrame(updateFlyMode);
|
||||
}
|
||||
};
|
||||
|
||||
// Check periodically if fly mode is active
|
||||
// 定期检查飞行模式是否激活
|
||||
const flyCheckInterval = setInterval(() => {
|
||||
if (isFlyModeRef.current && flyAnimationId === null) {
|
||||
startFlyLoop();
|
||||
}
|
||||
}, 100);
|
||||
|
||||
canvas.addEventListener('mousedown', handleMouseDown);
|
||||
canvas.addEventListener('wheel', handleWheel, { passive: false });
|
||||
canvas.addEventListener('contextmenu', handleContextMenu);
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
document.addEventListener('keyup', handleKeyUp);
|
||||
|
||||
return () => {
|
||||
if (rafId !== null) {
|
||||
cancelAnimationFrame(rafId);
|
||||
}
|
||||
if (flyAnimationId !== null) {
|
||||
cancelAnimationFrame(flyAnimationId);
|
||||
}
|
||||
clearInterval(flyCheckInterval);
|
||||
window.removeEventListener('resize', resizeCanvas);
|
||||
resizeObserver.disconnect();
|
||||
canvas.removeEventListener('mousedown', handleMouseDown);
|
||||
@@ -874,6 +1086,8 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport
|
||||
canvas.removeEventListener('contextmenu', handleContextMenu);
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
document.removeEventListener('keyup', handleKeyUp);
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -1884,8 +2098,10 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport
|
||||
// Check for supported asset types | 检查支持的资产类型
|
||||
const isPrefab = lowerPath.endsWith('.prefab');
|
||||
const isFui = lowerPath.endsWith('.fui');
|
||||
const is3DModel = lowerPath.endsWith('.gltf') || lowerPath.endsWith('.glb') ||
|
||||
lowerPath.endsWith('.obj') || lowerPath.endsWith('.fbx');
|
||||
|
||||
if (!isPrefab && !isFui) {
|
||||
if (!isPrefab && !isFui && !is3DModel) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1976,6 +2192,39 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport
|
||||
entityStore.selectEntity(entity);
|
||||
|
||||
console.log(`[Viewport] FGUI entity created at (${worldPos.x.toFixed(0)}, ${worldPos.y.toFixed(0)}): ${finalName}`);
|
||||
} else if (is3DModel) {
|
||||
// 处理 3D 模型文件 | Handle 3D model file
|
||||
const filename = assetPath.split(/[/\\]/).pop() || '3D Mesh';
|
||||
const entityName = filename.replace(/\.(gltf|glb|obj|fbx)$/i, '');
|
||||
|
||||
// 生成唯一名称 | Generate unique name
|
||||
const existingCount = entityStore.getAllEntities()
|
||||
.filter((ent: Entity) => ent.name.startsWith(entityName)).length;
|
||||
const finalName = existingCount > 0 ? `${entityName} ${existingCount + 1}` : entityName;
|
||||
|
||||
// 创建实体 | Create entity
|
||||
const entity = scene.createEntity(finalName);
|
||||
|
||||
// 添加 TransformComponent | Add TransformComponent
|
||||
const transform = new TransformComponent();
|
||||
transform.position.x = worldPos.x;
|
||||
transform.position.y = worldPos.y;
|
||||
entity.addComponent(transform);
|
||||
|
||||
// 添加 MeshComponent | Add MeshComponent
|
||||
const meshComponent = new MeshComponent();
|
||||
// 优先使用 GUID,如果没有则使用路径
|
||||
// Prefer GUID, fallback to path
|
||||
meshComponent.modelGuid = assetGuid || assetPath;
|
||||
entity.addComponent(meshComponent);
|
||||
|
||||
// 注册并选中实体 | Register and select entity
|
||||
entityStore.addEntity(entity);
|
||||
messageHub.publish('entity:added', { entity });
|
||||
messageHub.publish('scene:modified', {});
|
||||
entityStore.selectEntity(entity);
|
||||
|
||||
console.log(`[Viewport] 3D Mesh entity created at (${worldPos.x.toFixed(0)}, ${worldPos.y.toFixed(0)}): ${finalName}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Viewport] Failed to handle drop:', error);
|
||||
|
||||
@@ -9,22 +9,28 @@ interface AxisInputProps {
|
||||
axis: 'x' | 'y' | 'z';
|
||||
value: number;
|
||||
onChange: (value: number) => void;
|
||||
onChangeCommit?: (value: number) => void; // 拖拽结束时调用 | Called when drag ends
|
||||
suffix?: string;
|
||||
}
|
||||
|
||||
function AxisInput({ axis, value, onChange, suffix }: AxisInputProps) {
|
||||
function AxisInput({ axis, value, onChange, onChangeCommit, suffix }: AxisInputProps) {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [inputValue, setInputValue] = useState(String(value ?? 0));
|
||||
const dragStartRef = useRef({ x: 0, value: 0 });
|
||||
const currentValueRef = useRef(value ?? 0); // 跟踪当前值 | Track current value
|
||||
|
||||
useEffect(() => {
|
||||
setInputValue(String(value ?? 0));
|
||||
}, [value]);
|
||||
if (!isDragging) {
|
||||
setInputValue(String(value ?? 0));
|
||||
currentValueRef.current = value ?? 0;
|
||||
}
|
||||
}, [value, isDragging]);
|
||||
|
||||
const handleBarMouseDown = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
dragStartRef.current = { x: e.clientX, value: value ?? 0 };
|
||||
currentValueRef.current = value ?? 0;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -35,11 +41,14 @@ function AxisInput({ axis, value, onChange, suffix }: AxisInputProps) {
|
||||
const sensitivity = e.shiftKey ? 0.01 : e.ctrlKey ? 1 : 0.1;
|
||||
const newValue = dragStartRef.current.value + delta * sensitivity;
|
||||
const rounded = Math.round(newValue * 1000) / 1000;
|
||||
currentValueRef.current = rounded;
|
||||
setInputValue(String(rounded));
|
||||
onChange(rounded);
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsDragging(false);
|
||||
onChangeCommit?.(currentValueRef.current); // 拖拽结束时通知 | Notify when drag ends
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
@@ -49,7 +58,7 @@ function AxisInput({ axis, value, onChange, suffix }: AxisInputProps) {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
}, [isDragging, onChange]);
|
||||
}, [isDragging, onChange, onChangeCommit]);
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setInputValue(e.target.value);
|
||||
@@ -108,6 +117,7 @@ interface TransformRowProps {
|
||||
isLocked?: boolean;
|
||||
onLockChange?: (locked: boolean) => void;
|
||||
onChange: (value: { x: number; y: number; z: number }) => void;
|
||||
onChangeCommit?: () => void; // 拖拽结束时调用 | Called when drag ends
|
||||
onReset?: () => void;
|
||||
suffix?: string;
|
||||
showDivider?: boolean;
|
||||
@@ -120,26 +130,54 @@ function TransformRow({
|
||||
isLocked = false,
|
||||
onLockChange,
|
||||
onChange,
|
||||
onChangeCommit,
|
||||
onReset,
|
||||
suffix,
|
||||
showDivider = true
|
||||
}: TransformRowProps) {
|
||||
// 使用 ref 来跟踪当前值,避免在拖拽过程中因重新渲染而丢失
|
||||
// Use ref to track current value, avoiding loss during drag re-renders
|
||||
const currentValueRef = useRef({ x: value?.x ?? 0, y: value?.y ?? 0, z: value?.z ?? 0 });
|
||||
|
||||
useEffect(() => {
|
||||
currentValueRef.current = { x: value?.x ?? 0, y: value?.y ?? 0, z: value?.z ?? 0 };
|
||||
}, [value?.x, value?.y, value?.z]);
|
||||
|
||||
const handleAxisChange = (axis: 'x' | 'y' | 'z', newValue: number) => {
|
||||
// 使用 ref 中的当前值,确保即使在快速拖拽时也能正确读取
|
||||
// Use current value from ref to ensure correct reading during fast dragging
|
||||
const currentX = currentValueRef.current.x;
|
||||
const currentY = currentValueRef.current.y;
|
||||
const currentZ = currentValueRef.current.z;
|
||||
|
||||
let newVector: { x: number; y: number; z: number };
|
||||
|
||||
if (isLocked && showLock) {
|
||||
const oldVal = value[axis];
|
||||
const oldVal = axis === 'x' ? currentX : axis === 'y' ? currentY : currentZ;
|
||||
if (oldVal !== 0) {
|
||||
const ratio = newValue / oldVal;
|
||||
onChange({
|
||||
x: axis === 'x' ? newValue : value.x * ratio,
|
||||
y: axis === 'y' ? newValue : value.y * ratio,
|
||||
z: axis === 'z' ? newValue : value.z * ratio
|
||||
});
|
||||
newVector = {
|
||||
x: axis === 'x' ? newValue : currentX * ratio,
|
||||
y: axis === 'y' ? newValue : currentY * ratio,
|
||||
z: axis === 'z' ? newValue : currentZ * ratio
|
||||
};
|
||||
} else {
|
||||
onChange({ ...value, [axis]: newValue });
|
||||
newVector = {
|
||||
x: axis === 'x' ? newValue : currentX,
|
||||
y: axis === 'y' ? newValue : currentY,
|
||||
z: axis === 'z' ? newValue : currentZ
|
||||
};
|
||||
}
|
||||
} else {
|
||||
onChange({ ...value, [axis]: newValue });
|
||||
newVector = {
|
||||
x: axis === 'x' ? newValue : currentX,
|
||||
y: axis === 'y' ? newValue : currentY,
|
||||
z: axis === 'z' ? newValue : currentZ
|
||||
};
|
||||
}
|
||||
|
||||
currentValueRef.current = newVector;
|
||||
onChange(newVector);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -154,18 +192,21 @@ function TransformRow({
|
||||
axis="x"
|
||||
value={value?.x ?? 0}
|
||||
onChange={(v) => handleAxisChange('x', v)}
|
||||
onChangeCommit={onChangeCommit}
|
||||
suffix={suffix}
|
||||
/>
|
||||
<AxisInput
|
||||
axis="y"
|
||||
value={value?.y ?? 0}
|
||||
onChange={(v) => handleAxisChange('y', v)}
|
||||
onChangeCommit={onChangeCommit}
|
||||
suffix={suffix}
|
||||
/>
|
||||
<AxisInput
|
||||
axis="z"
|
||||
value={value?.z ?? 0}
|
||||
onChange={(v) => handleAxisChange('z', v)}
|
||||
onChangeCommit={onChangeCommit}
|
||||
suffix={suffix}
|
||||
/>
|
||||
</div>
|
||||
@@ -230,21 +271,54 @@ function TransformInspectorContent({ context }: { context: ComponentInspectorCon
|
||||
const [mobility, setMobility] = useState<'static' | 'stationary' | 'movable'>('static');
|
||||
const [, forceUpdate] = useState({});
|
||||
|
||||
// 拖拽过程中只更新 transform 值,不触发 UI 刷新
|
||||
// During dragging, only update transform value, don't trigger UI refresh
|
||||
const handlePositionChange = (value: { x: number; y: number; z: number }) => {
|
||||
transform.position = value;
|
||||
context.onChange?.('position', value);
|
||||
forceUpdate({});
|
||||
};
|
||||
|
||||
const handleRotationChange = (value: { x: number; y: number; z: number }) => {
|
||||
transform.rotation = value;
|
||||
context.onChange?.('rotation', value);
|
||||
forceUpdate({});
|
||||
};
|
||||
|
||||
const handleScaleChange = (value: { x: number; y: number; z: number }) => {
|
||||
transform.scale = value;
|
||||
context.onChange?.('scale', value);
|
||||
};
|
||||
|
||||
// 拖拽结束时通知外部并刷新 UI
|
||||
// Notify external and refresh UI when drag ends
|
||||
const handlePositionCommit = () => {
|
||||
context.onChange?.('position', transform.position);
|
||||
forceUpdate({});
|
||||
};
|
||||
|
||||
const handleRotationCommit = () => {
|
||||
context.onChange?.('rotation', transform.rotation);
|
||||
forceUpdate({});
|
||||
};
|
||||
|
||||
const handleScaleCommit = () => {
|
||||
context.onChange?.('scale', transform.scale);
|
||||
forceUpdate({});
|
||||
};
|
||||
|
||||
// Reset 操作立即生效
|
||||
// Reset operations take effect immediately
|
||||
const handlePositionReset = () => {
|
||||
transform.position = { x: 0, y: 0, z: 0 };
|
||||
context.onChange?.('position', transform.position);
|
||||
forceUpdate({});
|
||||
};
|
||||
|
||||
const handleRotationReset = () => {
|
||||
transform.rotation = { x: 0, y: 0, z: 0 };
|
||||
context.onChange?.('rotation', transform.rotation);
|
||||
forceUpdate({});
|
||||
};
|
||||
|
||||
const handleScaleReset = () => {
|
||||
transform.scale = { x: 1, y: 1, z: 1 };
|
||||
context.onChange?.('scale', transform.scale);
|
||||
forceUpdate({});
|
||||
};
|
||||
|
||||
@@ -254,13 +328,15 @@ function TransformInspectorContent({ context }: { context: ComponentInspectorCon
|
||||
label="Location"
|
||||
value={transform.position}
|
||||
onChange={handlePositionChange}
|
||||
onReset={() => handlePositionChange({ x: 0, y: 0, z: 0 })}
|
||||
onChangeCommit={handlePositionCommit}
|
||||
onReset={handlePositionReset}
|
||||
/>
|
||||
<TransformRow
|
||||
label="Rotation"
|
||||
value={transform.rotation}
|
||||
onChange={handleRotationChange}
|
||||
onReset={() => handleRotationChange({ x: 0, y: 0, z: 0 })}
|
||||
onChangeCommit={handleRotationCommit}
|
||||
onReset={handleRotationReset}
|
||||
suffix="°"
|
||||
/>
|
||||
<TransformRow
|
||||
@@ -270,7 +346,8 @@ function TransformInspectorContent({ context }: { context: ComponentInspectorCon
|
||||
isLocked={isScaleLocked}
|
||||
onLockChange={setIsScaleLocked}
|
||||
onChange={handleScaleChange}
|
||||
onReset={() => handleScaleChange({ x: 1, y: 1, z: 1 })}
|
||||
onChangeCommit={handleScaleCommit}
|
||||
onReset={handleScaleReset}
|
||||
showDivider={false}
|
||||
/>
|
||||
<div className="tf-divider" />
|
||||
|
||||
@@ -15,7 +15,8 @@ import {
|
||||
TextureServiceToken,
|
||||
DynamicAtlasServiceToken,
|
||||
CoordinateServiceToken,
|
||||
RenderConfigServiceToken
|
||||
RenderConfigServiceToken,
|
||||
EngineBridgeToken
|
||||
} from '@esengine/ecs-engine-bindgen';
|
||||
import { TransformComponent, TransformTypeToken, CanvasElementToken } from '@esengine/engine-core';
|
||||
import { SpriteComponent, SpriteAnimatorComponent, SpriteAnimatorSystemToken } from '@esengine/sprite';
|
||||
@@ -259,6 +260,9 @@ export class EngineService {
|
||||
// 创建服务注册表并注册核心服务
|
||||
// Create service registry and register core services
|
||||
const services = new PluginServiceRegistry();
|
||||
// 注册 EngineBridge(供 MeshRenderSystem 等系统使用)
|
||||
// Register EngineBridge (for systems like MeshRenderSystem)
|
||||
services.register(EngineBridgeToken, this._runtime.bridge);
|
||||
// 使用单一职责接口注册 EngineBridge | Register EngineBridge with single-responsibility interfaces
|
||||
services.register(TextureServiceToken, this._runtime.bridge);
|
||||
services.register(DynamicAtlasServiceToken, this._runtime.bridge);
|
||||
|
||||
@@ -736,6 +736,10 @@
|
||||
color: #ec407a;
|
||||
}
|
||||
|
||||
.asset-thumbnail-icon.model3d {
|
||||
color: #26a69a;
|
||||
}
|
||||
|
||||
/* ==================== Status Bar ==================== */
|
||||
.cb-status-bar {
|
||||
display: flex;
|
||||
@@ -878,3 +882,126 @@
|
||||
.cb-asset-grid::-webkit-scrollbar-thumb:hover {
|
||||
background: #4e4e4e;
|
||||
}
|
||||
|
||||
/* ==================== 3D Model Sub-Asset Expansion ==================== */
|
||||
|
||||
/* Expand button for expandable models */
|
||||
.cb-asset-expand-btn {
|
||||
position: absolute;
|
||||
left: 4px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
color: #888;
|
||||
cursor: pointer;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.cb-asset-expand-btn:hover {
|
||||
background: #3c3c3c;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.cb-asset-expand-btn.expanded {
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
/* Spinning animation for loading */
|
||||
.cb-asset-expand-btn .spinning {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Grid view adjustments for expandable items */
|
||||
.cb-asset-grid.grid .cb-asset-item {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.cb-asset-grid.grid .cb-asset-item .cb-asset-expand-btn {
|
||||
left: 2px;
|
||||
top: 2px;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* Sub-asset items in grid view */
|
||||
.cb-asset-grid.grid .cb-asset-item.sub-asset {
|
||||
background: #252530;
|
||||
border-left: 2px solid #3b82f6;
|
||||
padding-left: 16px;
|
||||
}
|
||||
|
||||
.cb-asset-grid.grid .cb-asset-item.sub-asset:hover {
|
||||
background: #2d2d38;
|
||||
}
|
||||
|
||||
.cb-asset-grid.grid .cb-asset-item.sub-asset.selected {
|
||||
background: #0a4780;
|
||||
}
|
||||
|
||||
.cb-asset-grid.grid .cb-asset-item.sub-asset .cb-asset-thumbnail {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
background: #202028;
|
||||
}
|
||||
|
||||
/* List view adjustments */
|
||||
.cb-asset-grid.list .cb-asset-item {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.cb-asset-grid.list .cb-asset-item .cb-asset-expand-btn {
|
||||
position: relative;
|
||||
left: auto;
|
||||
top: auto;
|
||||
transform: none;
|
||||
flex-shrink: 0;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
/* Sub-asset items in list view */
|
||||
.cb-asset-grid.list .cb-asset-item.sub-asset {
|
||||
padding-left: 32px;
|
||||
background: #252530;
|
||||
border-left: 2px solid #3b82f6;
|
||||
}
|
||||
|
||||
.cb-asset-grid.list .cb-asset-item.sub-asset:hover {
|
||||
background: #2d2d38;
|
||||
}
|
||||
|
||||
.cb-asset-grid.list .cb-asset-item.sub-asset.selected {
|
||||
background: #0a4780;
|
||||
}
|
||||
|
||||
/* Sub-asset icon colors */
|
||||
.asset-thumbnail-icon.sub-asset.mesh {
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.asset-thumbnail-icon.sub-asset.material {
|
||||
color: #8b5cf6;
|
||||
}
|
||||
|
||||
.asset-thumbnail-icon.sub-asset.animation {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.asset-thumbnail-icon.sub-asset.skeleton {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
/* Expanded model highlight */
|
||||
.cb-asset-item.expanded {
|
||||
border-bottom: 1px solid #3b82f6;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user