feat(fairygui): FairyGUI 完整集成 (#314)
* feat(fairygui): FairyGUI ECS 集成核心架构 实现 FairyGUI 的 ECS 原生集成,完全替代旧 UI 系统: 核心类: - GObject: UI 对象基类,支持变换、可见性、关联、齿轮 - GComponent: 容器组件,管理子对象和控制器 - GRoot: 根容器,管理焦点、弹窗、输入分发 - GGroup: 组容器,支持水平/垂直布局 抽象层: - DisplayObject: 显示对象基类 - EventDispatcher: 事件分发 - Timer: 计时器 - Stage: 舞台,管理输入和缩放 布局系统: - Relations: 约束关联管理 - RelationItem: 24 种关联类型 基础设施: - Controller: 状态控制器 - Transition: 过渡动画 - ScrollPane: 滚动面板 - UIPackage: 包管理 - ByteBuffer: 二进制解析 * refactor(ui): 删除旧 UI 系统,使用 FairyGUI 替代 * feat(fairygui): 实现 UI 控件 - 添加显示类:Image、TextField、Graph - 添加基础控件:GImage、GTextField、GGraph - 添加交互控件:GButton、GProgressBar、GSlider - 更新 IRenderCollector 支持 Graph 渲染 - 扩展 Controller 添加 selectedPageId - 添加 STATE_CHANGED 事件类型 * feat(fairygui): 现代化架构重构 - 增强 EventDispatcher 支持类型安全、优先级和传播控制 - 添加 PropertyBinding 响应式属性绑定系统 - 添加 ServiceContainer 依赖注入容器 - 添加 UIConfig 全局配置系统 - 添加 UIObjectFactory 对象工厂 - 实现 RenderBridge 渲染桥接层 - 实现 Canvas2DBackend 作为默认渲染后端 - 扩展 IRenderCollector 支持更多图元类型 * feat(fairygui): 九宫格渲染和资源加载修复 - 修复 FGUIUpdateSystem 支持路径和 GUID 两种加载方式 - 修复 GTextInput 同时设置 _displayObject 和 _textField - 实现九宫格渲染展开为 9 个子图元 - 添加 sourceWidth/sourceHeight 用于九宫格计算 - 添加 DOMTextRenderer 文本渲染层(临时方案) * fix(fairygui): 修复 GGraph 颜色读取 * feat(fairygui): 虚拟节点 Inspector 和文本渲染支持 * fix(fairygui): 编辑器状态刷新和遗留引用修复 - 修复切换 FGUI 包后组件列表未刷新问题 - 修复切换组件后 viewport 未清理旧内容问题 - 修复虚拟节点在包加载后未刷新问题 - 重构为事件驱动架构,移除轮询机制 - 修复 @esengine/ui 遗留引用,统一使用 @esengine/fairygui * fix: 移除 tsconfig 中的 @esengine/ui 引用
This commit is contained in:
@@ -9,7 +9,8 @@
|
||||
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||
import { Entity, Core, HierarchySystem, HierarchyComponent, EntityTags, isFolder, PrefabSerializer, ComponentRegistry, getComponentInstanceTypeName, PrefabInstanceComponent } from '@esengine/ecs-framework';
|
||||
import type { PrefabData, ComponentType } from '@esengine/ecs-framework';
|
||||
import { EntityStoreService, MessageHub, CommandManager, EntityCreationRegistry, EntityCreationTemplate, PrefabService, AssetRegistryService, ProjectService } from '@esengine/editor-core';
|
||||
import { EntityStoreService, MessageHub, CommandManager, EntityCreationRegistry, EntityCreationTemplate, PrefabService, AssetRegistryService, ProjectService, VirtualNodeRegistry } from '@esengine/editor-core';
|
||||
import type { IVirtualNode } from '@esengine/editor-core';
|
||||
import { useLocale } from '../hooks/useLocale';
|
||||
import { useHierarchyStore } from '../stores';
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
@@ -48,6 +49,36 @@ const categoryIconMap: Record<string, string> = {
|
||||
'other': 'MoreHorizontal',
|
||||
};
|
||||
|
||||
/**
|
||||
* Map virtual node types to Lucide icon names
|
||||
* 将虚拟节点类型映射到 Lucide 图标名称
|
||||
*/
|
||||
const virtualNodeIconMap: Record<string, string> = {
|
||||
'Component': 'LayoutGrid',
|
||||
'Image': 'Image',
|
||||
'Graph': 'Square',
|
||||
'TextField': 'Type',
|
||||
'RichTextField': 'FileText',
|
||||
'Button': 'MousePointer',
|
||||
'List': 'List',
|
||||
'Loader': 'Loader',
|
||||
'ProgressBar': 'BarChart',
|
||||
'Slider': 'Sliders',
|
||||
'ComboBox': 'ChevronDown',
|
||||
'ScrollPane': 'Scroll',
|
||||
'Group': 'FolderOpen',
|
||||
'MovieClip': 'Film',
|
||||
'TextInput': 'FormInput',
|
||||
};
|
||||
|
||||
/**
|
||||
* Get icon name for a virtual node type
|
||||
* 获取虚拟节点类型的图标名称
|
||||
*/
|
||||
function getVirtualNodeIcon(nodeType: string): string {
|
||||
return virtualNodeIconMap[nodeType] || 'Circle';
|
||||
}
|
||||
|
||||
// 实体类型到图标的映射
|
||||
const entityTypeIcons: Record<string, React.ReactNode> = {
|
||||
'World': <Mountain size={14} className="entity-type-icon world" />,
|
||||
@@ -78,6 +109,21 @@ interface EntityNode {
|
||||
depth: number;
|
||||
bIsFolder: boolean;
|
||||
hasChildren: boolean;
|
||||
/** Virtual nodes from components (e.g., FGUI internal nodes) | 组件的虚拟节点(如 FGUI 内部节点) */
|
||||
virtualNodes?: IVirtualNode[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Flattened list item - can be either an entity node or a virtual node
|
||||
* 扁平化列表项 - 可以是实体节点或虚拟节点
|
||||
*/
|
||||
interface FlattenedItem {
|
||||
type: 'entity' | 'virtual';
|
||||
entityNode?: EntityNode;
|
||||
virtualNode?: IVirtualNode;
|
||||
depth: number;
|
||||
parentEntityId: number;
|
||||
hasChildren: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -140,6 +186,15 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf
|
||||
const [showFilterMenu, setShowFilterMenu] = useState(false);
|
||||
const [editingEntityId, setEditingEntityId] = useState<number | null>(null);
|
||||
const [editingName, setEditingName] = useState('');
|
||||
// Expanded virtual node IDs (format: "entityId:virtualNodeId")
|
||||
// 展开的虚拟节点 ID(格式:"entityId:virtualNodeId")
|
||||
const [expandedVirtualIds, setExpandedVirtualIds] = useState<Set<string>>(new Set());
|
||||
// Selected virtual node (format: "entityId:virtualNodeId")
|
||||
// 选中的虚拟节点(格式:"entityId:virtualNodeId")
|
||||
const [selectedVirtualId, setSelectedVirtualId] = useState<string | null>(null);
|
||||
// Refresh counter to force virtual nodes recollection
|
||||
// 刷新计数器,用于强制重新收集虚拟节点
|
||||
const [virtualNodeRefreshKey, setVirtualNodeRefreshKey] = useState(0);
|
||||
const { t, locale } = useLocale();
|
||||
|
||||
// Ref for auto-scrolling to selected item | 选中项自动滚动 ref
|
||||
@@ -173,6 +228,10 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf
|
||||
|
||||
/**
|
||||
* 构建层级树结构
|
||||
* Build hierarchical tree structure
|
||||
*
|
||||
* Also collects virtual nodes from components using VirtualNodeRegistry.
|
||||
* 同时使用 VirtualNodeRegistry 收集组件的虚拟节点。
|
||||
*/
|
||||
const buildEntityTree = useCallback((rootEntities: Entity[]): EntityNode[] => {
|
||||
const scene = Core.scene;
|
||||
@@ -191,12 +250,17 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf
|
||||
}
|
||||
}
|
||||
|
||||
// Collect virtual nodes from components
|
||||
// 从组件收集虚拟节点
|
||||
const virtualNodes = VirtualNodeRegistry.getAllVirtualNodesForEntity(entity);
|
||||
|
||||
return {
|
||||
entity,
|
||||
children,
|
||||
depth,
|
||||
bIsFolder: bIsEntityFolder,
|
||||
hasChildren: children.length > 0
|
||||
hasChildren: children.length > 0 || virtualNodes.length > 0,
|
||||
virtualNodes: virtualNodes.length > 0 ? virtualNodes : undefined
|
||||
};
|
||||
};
|
||||
|
||||
@@ -205,17 +269,68 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf
|
||||
|
||||
/**
|
||||
* 扁平化树为带深度信息的列表(用于渲染)
|
||||
* Flatten tree to list with depth info (for rendering)
|
||||
*
|
||||
* Also includes virtual nodes when their parent entity is expanded.
|
||||
* 当父实体展开时,也包含虚拟节点。
|
||||
*/
|
||||
const flattenTree = useCallback((nodes: EntityNode[], expandedSet: Set<number>): EntityNode[] => {
|
||||
const result: EntityNode[] = [];
|
||||
const flattenTree = useCallback((
|
||||
nodes: EntityNode[],
|
||||
expandedSet: Set<number>,
|
||||
expandedVirtualSet: Set<string>
|
||||
): FlattenedItem[] => {
|
||||
const result: FlattenedItem[] = [];
|
||||
|
||||
// Flatten virtual nodes recursively
|
||||
// 递归扁平化虚拟节点
|
||||
const flattenVirtualNodes = (
|
||||
virtualNodes: IVirtualNode[],
|
||||
parentEntityId: number,
|
||||
baseDepth: number
|
||||
) => {
|
||||
for (const vnode of virtualNodes) {
|
||||
const vnodeKey = `${parentEntityId}:${vnode.id}`;
|
||||
const hasVChildren = vnode.children && vnode.children.length > 0;
|
||||
|
||||
result.push({
|
||||
type: 'virtual',
|
||||
virtualNode: vnode,
|
||||
depth: baseDepth,
|
||||
parentEntityId,
|
||||
hasChildren: hasVChildren
|
||||
});
|
||||
|
||||
// If virtual node is expanded, add its children
|
||||
// 如果虚拟节点已展开,添加其子节点
|
||||
if (hasVChildren && expandedVirtualSet.has(vnodeKey)) {
|
||||
flattenVirtualNodes(vnode.children, parentEntityId, baseDepth + 1);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const traverse = (nodeList: EntityNode[]) => {
|
||||
for (const node of nodeList) {
|
||||
result.push(node);
|
||||
// Add entity node
|
||||
result.push({
|
||||
type: 'entity',
|
||||
entityNode: node,
|
||||
depth: node.depth,
|
||||
parentEntityId: node.entity.id,
|
||||
hasChildren: node.hasChildren
|
||||
});
|
||||
|
||||
const bIsExpanded = expandedSet.has(node.entity.id);
|
||||
if (bIsExpanded && node.children.length > 0) {
|
||||
traverse(node.children);
|
||||
if (bIsExpanded) {
|
||||
// Add child entities
|
||||
if (node.children.length > 0) {
|
||||
traverse(node.children);
|
||||
}
|
||||
|
||||
// Add virtual nodes after entity children
|
||||
// 在实体子节点后添加虚拟节点
|
||||
if (node.virtualNodes && node.virtualNodes.length > 0) {
|
||||
flattenVirtualNodes(node.virtualNodes, node.entity.id, node.depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -226,13 +341,92 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf
|
||||
|
||||
/**
|
||||
* 层级树和扁平化列表
|
||||
* Hierarchy tree and flattened list
|
||||
*
|
||||
* virtualNodeRefreshKey is used to force rebuild when components change.
|
||||
* virtualNodeRefreshKey 用于在组件变化时强制重建。
|
||||
*/
|
||||
const entityTree = useMemo(() => buildEntityTree(entities), [entities, buildEntityTree]);
|
||||
const flattenedEntities = useMemo(
|
||||
() => expandedIds.has(-1) ? flattenTree(entityTree, expandedIds) : [],
|
||||
[entityTree, expandedIds, flattenTree]
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const entityTree = useMemo(() => buildEntityTree(entities), [entities, buildEntityTree, virtualNodeRefreshKey]);
|
||||
const flattenedItems = useMemo(
|
||||
() => expandedIds.has(-1) ? flattenTree(entityTree, expandedIds, expandedVirtualIds) : [],
|
||||
[entityTree, expandedIds, expandedVirtualIds, flattenTree]
|
||||
);
|
||||
|
||||
/**
|
||||
* Toggle virtual node expansion
|
||||
* 切换虚拟节点展开状态
|
||||
*/
|
||||
const toggleVirtualExpand = useCallback((parentEntityId: number, virtualNodeId: string) => {
|
||||
const key = `${parentEntityId}:${virtualNodeId}`;
|
||||
setExpandedVirtualIds(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) {
|
||||
next.delete(key);
|
||||
} else {
|
||||
next.add(key);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Handle virtual node click
|
||||
* 处理虚拟节点点击
|
||||
*/
|
||||
const handleVirtualNodeClick = useCallback((parentEntityId: number, virtualNode: IVirtualNode) => {
|
||||
const key = `${parentEntityId}:${virtualNode.id}`;
|
||||
setSelectedVirtualId(key);
|
||||
// Clear entity selection when selecting virtual node
|
||||
// 选择虚拟节点时清除实体选择
|
||||
setSelectedIds(new Set());
|
||||
|
||||
// Publish event for Inspector to display virtual node properties
|
||||
// 发布事件以便 Inspector 显示虚拟节点属性
|
||||
messageHub.publish('virtual-node:selected', {
|
||||
parentEntityId,
|
||||
virtualNodeId: virtualNode.id,
|
||||
virtualNode
|
||||
});
|
||||
}, [messageHub, setSelectedIds]);
|
||||
|
||||
// Subscribe to scene:modified to refresh virtual nodes when components change
|
||||
// 订阅 scene:modified 事件,当组件变化时刷新虚拟节点
|
||||
useEffect(() => {
|
||||
const unsubModified = messageHub.subscribe('scene:modified', () => {
|
||||
setVirtualNodeRefreshKey(prev => prev + 1);
|
||||
});
|
||||
|
||||
// Also subscribe to component-specific events
|
||||
// 同时订阅组件相关事件
|
||||
const unsubComponentAdded = messageHub.subscribe('component:added', () => {
|
||||
setVirtualNodeRefreshKey(prev => prev + 1);
|
||||
});
|
||||
const unsubComponentRemoved = messageHub.subscribe('component:removed', () => {
|
||||
setVirtualNodeRefreshKey(prev => prev + 1);
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubModified();
|
||||
unsubComponentAdded();
|
||||
unsubComponentRemoved();
|
||||
};
|
||||
}, [messageHub]);
|
||||
|
||||
// Subscribe to VirtualNodeRegistry changes (event-driven, no polling needed)
|
||||
// 订阅 VirtualNodeRegistry 变化(事件驱动,无需轮询)
|
||||
useEffect(() => {
|
||||
const unsubscribe = VirtualNodeRegistry.onChange((event) => {
|
||||
// Refresh if the changed entity is expanded
|
||||
// 如果变化的实体是展开的,则刷新
|
||||
if (expandedIds.has(event.entityId)) {
|
||||
setVirtualNodeRefreshKey(prev => prev + 1);
|
||||
}
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, [expandedIds]);
|
||||
|
||||
// 获取插件实体创建模板 | Get entity creation templates from plugins
|
||||
useEffect(() => {
|
||||
const updateTemplates = () => {
|
||||
@@ -257,6 +451,14 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf
|
||||
// Note: Scene/entity/remote subscriptions moved to useStoreSubscriptions
|
||||
|
||||
const handleEntityClick = (entity: Entity, e: React.MouseEvent) => {
|
||||
// Clear virtual node selection when selecting an entity
|
||||
// 选择实体时清除虚拟节点选择
|
||||
setSelectedVirtualId(null);
|
||||
|
||||
// Force refresh virtual nodes to pick up any newly loaded components
|
||||
// 强制刷新虚拟节点以获取新加载的组件
|
||||
setVirtualNodeRefreshKey(prev => prev + 1);
|
||||
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
setSelectedIds(prev => {
|
||||
const next = new Set(prev);
|
||||
@@ -927,22 +1129,26 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf
|
||||
}
|
||||
|
||||
// 方向键导航 | Arrow key navigation
|
||||
if ((e.key === 'ArrowUp' || e.key === 'ArrowDown') && flattenedEntities.length > 0) {
|
||||
// Only navigate entity nodes, skip virtual nodes
|
||||
// 只导航实体节点,跳过虚拟节点
|
||||
const entityItems = flattenedItems.filter(item => item.type === 'entity');
|
||||
if ((e.key === 'ArrowUp' || e.key === 'ArrowDown') && entityItems.length > 0) {
|
||||
e.preventDefault();
|
||||
const currentIndex = selectedId
|
||||
? flattenedEntities.findIndex(n => n.entity.id === selectedId)
|
||||
? entityItems.findIndex(item => item.entityNode?.entity.id === selectedId)
|
||||
: -1;
|
||||
|
||||
let newIndex: number;
|
||||
if (e.key === 'ArrowUp') {
|
||||
newIndex = currentIndex <= 0 ? flattenedEntities.length - 1 : currentIndex - 1;
|
||||
newIndex = currentIndex <= 0 ? entityItems.length - 1 : currentIndex - 1;
|
||||
} else {
|
||||
newIndex = currentIndex >= flattenedEntities.length - 1 ? 0 : currentIndex + 1;
|
||||
newIndex = currentIndex >= entityItems.length - 1 ? 0 : currentIndex + 1;
|
||||
}
|
||||
|
||||
const newEntity = flattenedEntities[newIndex]?.entity;
|
||||
const newEntity = entityItems[newIndex]?.entityNode?.entity;
|
||||
if (newEntity) {
|
||||
setSelectedIds(new Set([newEntity.id]));
|
||||
setSelectedVirtualId(null); // Clear virtual selection
|
||||
entityStore.selectEntity(newEntity);
|
||||
messageHub.publish('entity:selected', { entity: newEntity });
|
||||
}
|
||||
@@ -952,7 +1158,7 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [selectedId, isShowingRemote, editingEntityId, flattenedEntities, entityStore, messageHub, handleStartRename, handleConfirmRename, handleCancelRename, handleDuplicateEntity]);
|
||||
}, [selectedId, isShowingRemote, editingEntityId, flattenedItems, entityStore, messageHub, handleStartRename, handleConfirmRename, handleCancelRename, handleDuplicateEntity]);
|
||||
|
||||
/**
|
||||
* 创建文件夹实体
|
||||
@@ -1303,107 +1509,164 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hierarchical Entity Items */}
|
||||
{flattenedEntities.map((node) => {
|
||||
const { entity, depth, hasChildren, bIsFolder } = node;
|
||||
const bIsExpanded = expandedIds.has(entity.id);
|
||||
const bIsSelected = selectedIds.has(entity.id);
|
||||
const bIsDragging = draggedEntityId === entity.id;
|
||||
const currentDropTarget = dropTarget?.entityId === entity.id ? dropTarget : null;
|
||||
const bIsPrefabInstance = isEntityPrefabInstance(entity);
|
||||
{/* Hierarchical Entity and Virtual Node Items */}
|
||||
{flattenedItems.map((item, index) => {
|
||||
// Render entity node
|
||||
if (item.type === 'entity' && item.entityNode) {
|
||||
const node = item.entityNode;
|
||||
const { entity, bIsFolder } = node;
|
||||
const bIsExpanded = expandedIds.has(entity.id);
|
||||
const bIsSelected = selectedIds.has(entity.id);
|
||||
const bIsDragging = draggedEntityId === entity.id;
|
||||
const currentDropTarget = dropTarget?.entityId === entity.id ? dropTarget : null;
|
||||
const bIsPrefabInstance = isEntityPrefabInstance(entity);
|
||||
|
||||
// 计算缩进 (每层 16px,加上基础 8px)
|
||||
const indent = 8 + depth * 16;
|
||||
// 计算缩进 (每层 16px,加上基础 8px)
|
||||
const indent = 8 + item.depth * 16;
|
||||
|
||||
// 构建 drop indicator 类名
|
||||
let dropIndicatorClass = '';
|
||||
if (currentDropTarget) {
|
||||
dropIndicatorClass = `drop-${currentDropTarget.indicator}`;
|
||||
// 构建 drop indicator 类名
|
||||
let dropIndicatorClass = '';
|
||||
if (currentDropTarget) {
|
||||
dropIndicatorClass = `drop-${currentDropTarget.indicator}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`entity-${entity.id}`}
|
||||
ref={bIsSelected ? selectedItemRef : undefined}
|
||||
className={`outliner-item ${bIsSelected ? 'selected' : ''} ${bIsDragging ? 'dragging' : ''} ${dropIndicatorClass} ${bIsPrefabInstance ? 'prefab-instance' : ''}`}
|
||||
style={{ paddingLeft: `${indent}px` }}
|
||||
draggable
|
||||
onClick={(e) => handleEntityClick(entity, e)}
|
||||
onDragStart={(e) => handleDragStart(e, entity.id)}
|
||||
onDragOver={(e) => handleDragOver(e, node)}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={(e) => handleDrop(e, node)}
|
||||
onDragEnd={handleDragEnd}
|
||||
onContextMenu={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEntityClick(entity, e);
|
||||
handleContextMenu(e, entity.id);
|
||||
}}
|
||||
>
|
||||
<div className="outliner-item-icons">
|
||||
{isEntityVisible(entity) ? (
|
||||
<Eye
|
||||
size={12}
|
||||
className="item-icon visibility"
|
||||
onClick={(e) => handleToggleVisibility(entity, e)}
|
||||
/>
|
||||
) : (
|
||||
<EyeOff
|
||||
size={12}
|
||||
className="item-icon visibility hidden"
|
||||
onClick={(e) => handleToggleVisibility(entity, e)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="outliner-item-content">
|
||||
{/* 展开/折叠按钮 */}
|
||||
{item.hasChildren || bIsFolder ? (
|
||||
<span
|
||||
className="outliner-item-expand clickable"
|
||||
onClick={(e) => { e.stopPropagation(); toggleExpand(entity.id); }}
|
||||
>
|
||||
{bIsExpanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
||||
</span>
|
||||
) : (
|
||||
<span className="outliner-item-expand" />
|
||||
)}
|
||||
{getEntityIcon(entity)}
|
||||
{editingEntityId === entity.id ? (
|
||||
<input
|
||||
className="outliner-item-name-input"
|
||||
value={editingName}
|
||||
onChange={(e) => setEditingName(e.target.value)}
|
||||
onBlur={handleConfirmRename}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleConfirmRename();
|
||||
if (e.key === 'Escape') handleCancelRename();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className="outliner-item-name"
|
||||
onDoubleClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setEditingEntityId(entity.id);
|
||||
setEditingName(entity.name || '');
|
||||
}}
|
||||
>
|
||||
{entity.name || `Entity ${entity.id}`}
|
||||
</span>
|
||||
)}
|
||||
{/* 预制体实例徽章 | Prefab instance badge */}
|
||||
{bIsPrefabInstance && (
|
||||
<span className="prefab-badge" title={t('inspector.prefab.instance', {}, 'Prefab Instance')}>
|
||||
P
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="outliner-item-type">{getEntityType(entity)}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={entity.id}
|
||||
ref={bIsSelected ? selectedItemRef : undefined}
|
||||
className={`outliner-item ${bIsSelected ? 'selected' : ''} ${bIsDragging ? 'dragging' : ''} ${dropIndicatorClass} ${bIsPrefabInstance ? 'prefab-instance' : ''}`}
|
||||
style={{ paddingLeft: `${indent}px` }}
|
||||
draggable
|
||||
onClick={(e) => handleEntityClick(entity, e)}
|
||||
onDragStart={(e) => handleDragStart(e, entity.id)}
|
||||
onDragOver={(e) => handleDragOver(e, node)}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={(e) => handleDrop(e, node)}
|
||||
onDragEnd={handleDragEnd}
|
||||
onContextMenu={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEntityClick(entity, e);
|
||||
handleContextMenu(e, entity.id);
|
||||
}}
|
||||
>
|
||||
<div className="outliner-item-icons">
|
||||
{isEntityVisible(entity) ? (
|
||||
<Eye
|
||||
size={12}
|
||||
className="item-icon visibility"
|
||||
onClick={(e) => handleToggleVisibility(entity, e)}
|
||||
/>
|
||||
) : (
|
||||
<EyeOff
|
||||
size={12}
|
||||
className="item-icon visibility hidden"
|
||||
onClick={(e) => handleToggleVisibility(entity, e)}
|
||||
/>
|
||||
)}
|
||||
// Render virtual node (read-only)
|
||||
// 渲染虚拟节点(只读)
|
||||
if (item.type === 'virtual' && item.virtualNode) {
|
||||
const vnode = item.virtualNode;
|
||||
const vnodeKey = `${item.parentEntityId}:${vnode.id}`;
|
||||
const bIsVExpanded = expandedVirtualIds.has(vnodeKey);
|
||||
const bIsVSelected = selectedVirtualId === vnodeKey;
|
||||
|
||||
// 计算缩进
|
||||
const indent = 8 + item.depth * 16;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`virtual-${vnodeKey}-${index}`}
|
||||
className={`outliner-item virtual-node ${bIsVSelected ? 'selected' : ''} ${!vnode.visible ? 'hidden-node' : ''}`}
|
||||
style={{ paddingLeft: `${indent}px` }}
|
||||
onClick={() => handleVirtualNodeClick(item.parentEntityId, vnode)}
|
||||
>
|
||||
<div className="outliner-item-icons">
|
||||
{vnode.visible ? (
|
||||
<Eye size={12} className="item-icon visibility" />
|
||||
) : (
|
||||
<EyeOff size={12} className="item-icon visibility hidden" />
|
||||
)}
|
||||
</div>
|
||||
<div className="outliner-item-content">
|
||||
{/* 展开/折叠按钮 */}
|
||||
{item.hasChildren ? (
|
||||
<span
|
||||
className="outliner-item-expand clickable"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleVirtualExpand(item.parentEntityId, vnode.id);
|
||||
}}
|
||||
>
|
||||
{bIsVExpanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
||||
</span>
|
||||
) : (
|
||||
<span className="outliner-item-expand" />
|
||||
)}
|
||||
{/* 虚拟节点类型图标 */}
|
||||
{getIconComponent(getVirtualNodeIcon(vnode.type), 14)}
|
||||
<span className="outliner-item-name virtual-name">
|
||||
{vnode.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="outliner-item-type virtual-type">{vnode.type}</div>
|
||||
</div>
|
||||
<div className="outliner-item-content">
|
||||
{/* 展开/折叠按钮 */}
|
||||
{hasChildren || bIsFolder ? (
|
||||
<span
|
||||
className="outliner-item-expand clickable"
|
||||
onClick={(e) => { e.stopPropagation(); toggleExpand(entity.id); }}
|
||||
>
|
||||
{bIsExpanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
||||
</span>
|
||||
) : (
|
||||
<span className="outliner-item-expand" />
|
||||
)}
|
||||
{getEntityIcon(entity)}
|
||||
{editingEntityId === entity.id ? (
|
||||
<input
|
||||
className="outliner-item-name-input"
|
||||
value={editingName}
|
||||
onChange={(e) => setEditingName(e.target.value)}
|
||||
onBlur={handleConfirmRename}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleConfirmRename();
|
||||
if (e.key === 'Escape') handleCancelRename();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className="outliner-item-name"
|
||||
onDoubleClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setEditingEntityId(entity.id);
|
||||
setEditingName(entity.name || '');
|
||||
}}
|
||||
>
|
||||
{entity.name || `Entity ${entity.id}`}
|
||||
</span>
|
||||
)}
|
||||
{/* 预制体实例徽章 | Prefab instance badge */}
|
||||
{bIsPrefabInstance && (
|
||||
<span className="prefab-badge" title={t('inspector.prefab.instance', {}, 'Prefab Instance')}>
|
||||
P
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="outliner-item-type">{getEntityType(entity)}</div>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -184,14 +184,23 @@ export function SettingsWindow({ onClose, settingsRegistry, initialCategoryId }:
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[SettingsWindow] Initial values for profiler:',
|
||||
Array.from(initialValues.entries()).filter(([k]) => k.startsWith('profiler.')));
|
||||
setValues(initialValues);
|
||||
}, [settingsRegistry, initialCategoryId]);
|
||||
|
||||
const handleValueChange = (key: string, value: any, descriptor: SettingDescriptor) => {
|
||||
const newValues = new Map(values);
|
||||
newValues.set(key, value);
|
||||
|
||||
// When preset is selected, also update width and height values
|
||||
// 当选择预设时,同时更新宽度和高度值
|
||||
if (key === 'project.uiDesignResolution.preset' && typeof value === 'string' && value.includes('x')) {
|
||||
const [w, h] = value.split('x').map(Number);
|
||||
if (w && h) {
|
||||
newValues.set('project.uiDesignResolution.width', w);
|
||||
newValues.set('project.uiDesignResolution.height', h);
|
||||
}
|
||||
}
|
||||
|
||||
setValues(newValues);
|
||||
|
||||
const newErrors = new Map(errors);
|
||||
@@ -218,7 +227,6 @@ export function SettingsWindow({ onClose, settingsRegistry, initialCategoryId }:
|
||||
|
||||
if (!shouldDeferSave) {
|
||||
settings.set(key, value);
|
||||
console.log(`[SettingsWindow] Saved ${key}:`, value);
|
||||
|
||||
// 触发设置变更事件
|
||||
// Trigger settings changed event
|
||||
@@ -237,28 +245,27 @@ export function SettingsWindow({ onClose, settingsRegistry, initialCategoryId }:
|
||||
const projectService = Core.services.tryResolve<ProjectService>(ProjectService);
|
||||
const changedSettings: Record<string, any> = {};
|
||||
|
||||
let uiResolutionChanged = false;
|
||||
let newWidth = 1920;
|
||||
let newHeight = 1080;
|
||||
// Get width and height directly from values - these are the actual UI input values
|
||||
// 直接从 values 获取宽高 - 这些是实际的 UI 输入值
|
||||
const widthFromValues = values.get('project.uiDesignResolution.width');
|
||||
const heightFromValues = values.get('project.uiDesignResolution.height');
|
||||
|
||||
// Use the width/height values directly (they are always set from either user input or initial load)
|
||||
// 直接使用 width/height 值(它们总是从用户输入或初始加载设置的)
|
||||
const newWidth = typeof widthFromValues === 'number' ? widthFromValues : 1920;
|
||||
const newHeight = typeof heightFromValues === 'number' ? heightFromValues : 1080;
|
||||
|
||||
// Check if resolution differs from saved config
|
||||
// 检查分辨率是否与保存的配置不同
|
||||
const currentResolution = projectService?.getUIDesignResolution() || { width: 1920, height: 1080 };
|
||||
const uiResolutionChanged = newWidth !== currentResolution.width || newHeight !== currentResolution.height;
|
||||
|
||||
let disabledModulesChanged = false;
|
||||
let newDisabledModules: string[] = [];
|
||||
|
||||
for (const [key, value] of values.entries()) {
|
||||
if (key.startsWith('project.') && projectService) {
|
||||
if (key === 'project.uiDesignResolution.width') {
|
||||
newWidth = value;
|
||||
uiResolutionChanged = true;
|
||||
} else if (key === 'project.uiDesignResolution.height') {
|
||||
newHeight = value;
|
||||
uiResolutionChanged = true;
|
||||
} else if (key === 'project.uiDesignResolution.preset') {
|
||||
const [w, h] = value.split('x').map(Number);
|
||||
if (w && h) {
|
||||
newWidth = w;
|
||||
newHeight = h;
|
||||
uiResolutionChanged = true;
|
||||
}
|
||||
} else if (key === 'project.disabledModules') {
|
||||
if (key === 'project.disabledModules') {
|
||||
newDisabledModules = value as string[];
|
||||
disabledModulesChanged = true;
|
||||
}
|
||||
@@ -270,7 +277,9 @@ export function SettingsWindow({ onClose, settingsRegistry, initialCategoryId }:
|
||||
}
|
||||
|
||||
if (uiResolutionChanged && projectService) {
|
||||
console.log(`[SettingsWindow] Saving UI resolution: ${newWidth}x${newHeight}`);
|
||||
await projectService.setUIDesignResolution({ width: newWidth, height: newHeight });
|
||||
console.log(`[SettingsWindow] UI resolution saved, verifying: ${JSON.stringify(projectService.getUIDesignResolution())}`);
|
||||
}
|
||||
|
||||
if (disabledModulesChanged && projectService) {
|
||||
@@ -570,14 +579,14 @@ export function SettingsWindow({ onClose, settingsRegistry, initialCategoryId }:
|
||||
const selectedCategory = categories.find((c) => c.id === selectedCategoryId);
|
||||
|
||||
return (
|
||||
<div className="settings-overlay" onClick={handleCancel}>
|
||||
<div className="settings-overlay" onClick={handleSave}>
|
||||
<div className="settings-window-new" onClick={(e) => e.stopPropagation()}>
|
||||
{/* Left Sidebar */}
|
||||
<div className="settings-sidebar-new">
|
||||
<div className="settings-sidebar-header">
|
||||
<SettingsIcon size={16} />
|
||||
<span>{t('settingsWindow.editorPreferences')}</span>
|
||||
<button className="settings-sidebar-close" onClick={handleCancel}>
|
||||
<button className="settings-sidebar-close" onClick={handleSave}>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -10,12 +10,12 @@ import { useLocale } from '../hooks/useLocale';
|
||||
import { EngineService } from '../services/EngineService';
|
||||
import { Core, Entity, SceneSerializer, PrefabSerializer, GlobalComponentRegistry } from '@esengine/ecs-framework';
|
||||
import type { PrefabData, ComponentType } from '@esengine/ecs-framework';
|
||||
import { MessageHub, ProjectService, AssetRegistryService, EntityStoreService, CommandManager, SceneManagerService, UserCodeService, UserCodeTarget } from '@esengine/editor-core';
|
||||
import { MessageHub, ProjectService, AssetRegistryService, EntityStoreService, CommandManager, SceneManagerService, UserCodeService, UserCodeTarget, VirtualNodeRegistry } from '@esengine/editor-core';
|
||||
import { InstantiatePrefabCommand } from '../application/commands/prefab/InstantiatePrefabCommand';
|
||||
import { TransformCommand, type TransformState, type TransformOperationType } from '../application/commands';
|
||||
import { TransformComponent } from '@esengine/engine-core';
|
||||
import { CameraComponent } from '@esengine/camera';
|
||||
import { UITransformComponent } from '@esengine/ui';
|
||||
import { FGUIComponent } from '@esengine/fairygui';
|
||||
import { TauriAPI } from '../api/tauri';
|
||||
import { open } from '@tauri-apps/plugin-shell';
|
||||
import { RuntimeResolver } from '../services/RuntimeResolver';
|
||||
@@ -302,7 +302,7 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport
|
||||
const transformModeRef = useRef<TransformMode>('select');
|
||||
// Initial transform state for undo/redo | 用于撤销/重做的初始变换状态
|
||||
const initialTransformStateRef = useRef<TransformState | null>(null);
|
||||
const transformComponentRef = useRef<TransformComponent | UITransformComponent | null>(null);
|
||||
const transformComponentRef = useRef<TransformComponent | null>(null);
|
||||
const snapEnabledRef = useRef(true);
|
||||
const gridSnapRef = useRef(10);
|
||||
const rotationSnapRef = useRef(15);
|
||||
@@ -454,18 +454,48 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport
|
||||
if (gizmoService) {
|
||||
const worldPos = screenToWorld(e.clientX, e.clientY);
|
||||
const zoom = camera2DZoomRef.current;
|
||||
const hitEntityId = gizmoService.handleClick(worldPos.x, worldPos.y, zoom);
|
||||
const clickResult = gizmoService.handleClickEx(worldPos.x, worldPos.y, zoom);
|
||||
|
||||
if (hitEntityId !== null) {
|
||||
if (clickResult !== null) {
|
||||
// Find and select the hit entity
|
||||
// 找到并选中命中的实体
|
||||
const scene = Core.scene;
|
||||
if (scene) {
|
||||
const hitEntity = scene.entities.findEntityById(hitEntityId);
|
||||
const hitEntity = scene.entities.findEntityById(clickResult.entityId);
|
||||
if (hitEntity && messageHubRef.current) {
|
||||
const entityStore = Core.services.tryResolve(EntityStoreService);
|
||||
entityStore?.selectEntity(hitEntity);
|
||||
messageHubRef.current.publish('entity:selected', { entity: hitEntity });
|
||||
|
||||
// Check if clicked on a virtual node
|
||||
// 检查是否点击了虚拟节点
|
||||
if (clickResult.virtualNodeId) {
|
||||
// Get the virtual node data from VirtualNodeRegistry
|
||||
// 从 VirtualNodeRegistry 获取虚拟节点数据
|
||||
const virtualNodes = VirtualNodeRegistry.getAllVirtualNodesForEntity(hitEntity);
|
||||
const findVirtualNode = (nodes: typeof virtualNodes, targetId: string): typeof virtualNodes[0] | null => {
|
||||
for (const node of nodes) {
|
||||
if (node.id === targetId) return node;
|
||||
const found = findVirtualNode(node.children, targetId);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const virtualNode = findVirtualNode(virtualNodes, clickResult.virtualNodeId);
|
||||
|
||||
if (virtualNode) {
|
||||
// Publish virtual-node:selected event (will trigger Inspector update)
|
||||
// 发布 virtual-node:selected 事件(将触发 Inspector 更新)
|
||||
messageHubRef.current.publish('virtual-node:selected', {
|
||||
parentEntityId: clickResult.entityId,
|
||||
virtualNodeId: clickResult.virtualNodeId,
|
||||
virtualNode
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Normal entity selection
|
||||
// 普通实体选择
|
||||
messageHubRef.current.publish('entity:selected', { entity: hitEntity });
|
||||
}
|
||||
e.preventDefault();
|
||||
return; // Don't start camera pan
|
||||
}
|
||||
@@ -487,13 +517,9 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport
|
||||
const entity = selectedEntityRef.current;
|
||||
if (entity) {
|
||||
const transform = entity.getComponent(TransformComponent);
|
||||
const uiTransform = entity.getComponent(UITransformComponent);
|
||||
if (transform) {
|
||||
initialTransformStateRef.current = TransformCommand.captureTransformState(transform);
|
||||
transformComponentRef.current = transform;
|
||||
} else if (uiTransform) {
|
||||
initialTransformStateRef.current = TransformCommand.captureUITransformState(uiTransform);
|
||||
transformComponentRef.current = uiTransform;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -573,63 +599,6 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport
|
||||
}
|
||||
}
|
||||
|
||||
// Try UITransformComponent
|
||||
const uiTransform = entity.getComponent(UITransformComponent);
|
||||
if (uiTransform) {
|
||||
if (mode === 'move') {
|
||||
uiTransform.x += worldDelta.x;
|
||||
uiTransform.y += worldDelta.y;
|
||||
} else if (mode === 'rotate') {
|
||||
const rotationSpeed = 0.01;
|
||||
uiTransform.rotation += deltaX * rotationSpeed;
|
||||
} else if (mode === 'scale') {
|
||||
const oldWidth = uiTransform.width * uiTransform.scaleX;
|
||||
const oldHeight = uiTransform.height * uiTransform.scaleY;
|
||||
|
||||
// pivot点的世界坐标(缩放前)
|
||||
const pivotWorldX = uiTransform.x + oldWidth * uiTransform.pivotX;
|
||||
const pivotWorldY = uiTransform.y + oldHeight * uiTransform.pivotY;
|
||||
|
||||
const startDist = Math.sqrt((worldStart.x - pivotWorldX) ** 2 + (worldStart.y - pivotWorldY) ** 2);
|
||||
const endDist = Math.sqrt((worldEnd.x - pivotWorldX) ** 2 + (worldEnd.y - pivotWorldY) ** 2);
|
||||
|
||||
if (startDist > 0) {
|
||||
const scaleFactor = endDist / startDist;
|
||||
const newScaleX = uiTransform.scaleX * scaleFactor;
|
||||
const newScaleY = uiTransform.scaleY * scaleFactor;
|
||||
|
||||
const newWidth = uiTransform.width * newScaleX;
|
||||
const newHeight = uiTransform.height * newScaleY;
|
||||
|
||||
// 调整位置使pivot点保持不动
|
||||
uiTransform.x = pivotWorldX - newWidth * uiTransform.pivotX;
|
||||
uiTransform.y = pivotWorldY - newHeight * uiTransform.pivotY;
|
||||
uiTransform.scaleX = newScaleX;
|
||||
uiTransform.scaleY = newScaleY;
|
||||
}
|
||||
}
|
||||
|
||||
// Update live transform display for UI | 更新 UI 的实时变换显示
|
||||
setLiveTransform({
|
||||
type: mode as 'move' | 'rotate' | 'scale',
|
||||
x: uiTransform.x,
|
||||
y: uiTransform.y,
|
||||
rotation: uiTransform.rotation * 180 / Math.PI,
|
||||
scaleX: uiTransform.scaleX,
|
||||
scaleY: uiTransform.scaleY
|
||||
});
|
||||
|
||||
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]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
lastMousePosRef.current = { x: e.clientX, y: e.clientY };
|
||||
} else {
|
||||
// Not dragging - update gizmo hover state
|
||||
@@ -683,18 +652,6 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create TransformCommand for undo/redo | 创建变换命令用于撤销/重做
|
||||
@@ -705,13 +662,7 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport
|
||||
|
||||
if (entity && initialState && component && hub && cmdManager) {
|
||||
const mode = transformModeRef.current as TransformOperationType;
|
||||
let newState: TransformState;
|
||||
|
||||
if (component instanceof TransformComponent) {
|
||||
newState = TransformCommand.captureTransformState(component);
|
||||
} else {
|
||||
newState = TransformCommand.captureUITransformState(component as UITransformComponent);
|
||||
}
|
||||
const newState = TransformCommand.captureTransformState(component);
|
||||
|
||||
// Only create command if state actually changed | 只有状态实际改变时才创建命令
|
||||
const hasChanged = JSON.stringify(initialState) !== JSON.stringify(newState);
|
||||
@@ -1715,58 +1666,115 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport
|
||||
|
||||
const handleViewportDrop = useCallback(async (e: React.DragEvent) => {
|
||||
const assetPath = e.dataTransfer.getData('asset-path');
|
||||
if (!assetPath || !assetPath.toLowerCase().endsWith('.prefab')) {
|
||||
const assetGuid = e.dataTransfer.getData('asset-guid');
|
||||
const lowerPath = assetPath?.toLowerCase() || '';
|
||||
|
||||
if (!assetPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for supported asset types | 检查支持的资产类型
|
||||
const isPrefab = lowerPath.endsWith('.prefab');
|
||||
const isFui = lowerPath.endsWith('.fui');
|
||||
|
||||
if (!isPrefab && !isFui) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
// 获取服务 | Get services
|
||||
const entityStore = Core.services.tryResolve(EntityStoreService) as EntityStoreService | null;
|
||||
const scene = Core.scene;
|
||||
|
||||
if (!entityStore || !scene || !messageHub) {
|
||||
console.error('[Viewport] Required services not available');
|
||||
return;
|
||||
}
|
||||
|
||||
// 计算放置位置(将屏幕坐标转换为世界坐标)| Calculate drop position (convert screen to world)
|
||||
const canvas = canvasRef.current;
|
||||
let worldPos = { x: 0, y: 0 };
|
||||
if (canvas) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const screenX = e.clientX - rect.left;
|
||||
const screenY = e.clientY - rect.top;
|
||||
const canvasX = screenX * dpr;
|
||||
const canvasY = screenY * dpr;
|
||||
const centeredX = canvasX - canvas.width / 2;
|
||||
const centeredY = canvas.height / 2 - canvasY;
|
||||
worldPos = {
|
||||
x: centeredX / camera2DZoomRef.current - camera2DOffsetRef.current.x,
|
||||
y: centeredY / camera2DZoomRef.current - camera2DOffsetRef.current.y
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// 读取预制体文件 | Read prefab file
|
||||
const prefabJson = await TauriAPI.readFileContent(assetPath);
|
||||
const prefabData = PrefabSerializer.deserialize(prefabJson);
|
||||
|
||||
// 获取服务 | Get services
|
||||
const entityStore = Core.services.tryResolve(EntityStoreService) as EntityStoreService | null;
|
||||
|
||||
if (!entityStore || !messageHub || !commandManager) {
|
||||
console.error('[Viewport] Required services not available');
|
||||
return;
|
||||
}
|
||||
|
||||
// 计算放置位置(将屏幕坐标转换为世界坐标)| Calculate drop position (convert screen to world)
|
||||
const canvas = canvasRef.current;
|
||||
let worldPos = { x: 0, y: 0 };
|
||||
if (canvas) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const screenX = e.clientX - rect.left;
|
||||
const screenY = e.clientY - rect.top;
|
||||
const canvasX = screenX * dpr;
|
||||
const canvasY = screenY * dpr;
|
||||
const centeredX = canvasX - canvas.width / 2;
|
||||
const centeredY = canvas.height / 2 - canvasY;
|
||||
worldPos = {
|
||||
x: centeredX / camera2DZoomRef.current - camera2DOffsetRef.current.x,
|
||||
y: centeredY / camera2DZoomRef.current - camera2DOffsetRef.current.y
|
||||
};
|
||||
}
|
||||
|
||||
// 创建实例化命令 | Create instantiate command
|
||||
const command = new InstantiatePrefabCommand(
|
||||
entityStore,
|
||||
messageHub,
|
||||
prefabData,
|
||||
{
|
||||
position: worldPos,
|
||||
trackInstance: true
|
||||
if (isPrefab) {
|
||||
// 处理预制体 | Handle prefab
|
||||
if (!commandManager) {
|
||||
console.error('[Viewport] CommandManager not available');
|
||||
return;
|
||||
}
|
||||
);
|
||||
commandManager.execute(command);
|
||||
|
||||
console.log(`[Viewport] Prefab instantiated at (${worldPos.x.toFixed(0)}, ${worldPos.y.toFixed(0)}): ${prefabData.metadata.name}`);
|
||||
const prefabJson = await TauriAPI.readFileContent(assetPath);
|
||||
const prefabData = PrefabSerializer.deserialize(prefabJson);
|
||||
|
||||
const command = new InstantiatePrefabCommand(
|
||||
entityStore,
|
||||
messageHub,
|
||||
prefabData,
|
||||
{
|
||||
position: worldPos,
|
||||
trackInstance: true
|
||||
}
|
||||
);
|
||||
commandManager.execute(command);
|
||||
|
||||
console.log(`[Viewport] Prefab instantiated at (${worldPos.x.toFixed(0)}, ${worldPos.y.toFixed(0)}): ${prefabData.metadata.name}`);
|
||||
} else if (isFui) {
|
||||
// 处理 FUI 文件 | Handle FUI file
|
||||
const filename = assetPath.split(/[/\\]/).pop() || 'FGUI View';
|
||||
const entityName = filename.replace('.fui', '');
|
||||
|
||||
// 生成唯一名称 | 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);
|
||||
|
||||
// 添加 FGUIComponent | Add FGUIComponent
|
||||
const fguiComponent = new FGUIComponent();
|
||||
// 优先使用 GUID,如果没有则使用路径(编辑器会通过 AssetRegistry 解析)
|
||||
// Prefer GUID, fallback to path (editor resolves via AssetRegistry)
|
||||
fguiComponent.packageGuid = assetGuid || assetPath;
|
||||
fguiComponent.width = 1920;
|
||||
fguiComponent.height = 1080;
|
||||
entity.addComponent(fguiComponent);
|
||||
|
||||
// 注册并选中实体 | Register and select entity
|
||||
entityStore.addEntity(entity);
|
||||
messageHub.publish('entity:added', { entity });
|
||||
messageHub.publish('scene:modified', {});
|
||||
entityStore.selectEntity(entity);
|
||||
|
||||
console.log(`[Viewport] FGUI entity created at (${worldPos.x.toFixed(0)}, ${worldPos.y.toFixed(0)}): ${finalName}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Viewport] Failed to instantiate prefab:', error);
|
||||
console.error('[Viewport] Failed to handle drop:', error);
|
||||
messageHub?.publish('notification:error', {
|
||||
title: 'Drop Failed',
|
||||
message: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
}
|
||||
}, [messageHub, commandManager]);
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -15,7 +15,8 @@ import {
|
||||
ExtensionInspector,
|
||||
AssetFileInspector,
|
||||
RemoteEntityInspector,
|
||||
PrefabInspector
|
||||
PrefabInspector,
|
||||
VirtualNodeInspector
|
||||
} from './views';
|
||||
import { EntityInspectorPanel } from '../inspector';
|
||||
|
||||
@@ -112,5 +113,14 @@ export function Inspector({ entityStore: _entityStore, messageHub, inspectorRegi
|
||||
);
|
||||
}
|
||||
|
||||
if (target.type === 'virtual-node') {
|
||||
return (
|
||||
<VirtualNodeInspector
|
||||
parentEntityId={target.data.parentEntityId}
|
||||
virtualNode={target.data.virtualNode}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Entity } from '@esengine/ecs-framework';
|
||||
import { EntityStoreService, MessageHub, InspectorRegistry, CommandManager } from '@esengine/editor-core';
|
||||
import type { IVirtualNode } from '@esengine/editor-core';
|
||||
|
||||
export interface InspectorProps {
|
||||
entityStore: EntityStoreService;
|
||||
@@ -20,11 +21,22 @@ export interface AssetFileInfo {
|
||||
|
||||
type ExtensionData = Record<string, any>;
|
||||
|
||||
/**
|
||||
* Virtual node target data
|
||||
* 虚拟节点目标数据
|
||||
*/
|
||||
export interface VirtualNodeTargetData {
|
||||
parentEntityId: number;
|
||||
virtualNodeId: string;
|
||||
virtualNode: IVirtualNode;
|
||||
}
|
||||
|
||||
export type InspectorTarget =
|
||||
| { type: 'entity'; data: Entity }
|
||||
| { type: 'remote-entity'; data: RemoteEntity; details?: EntityDetails }
|
||||
| { type: 'asset-file'; data: AssetFileInfo; content?: string; isImage?: boolean }
|
||||
| { type: 'extension'; data: ExtensionData }
|
||||
| { type: 'virtual-node'; data: VirtualNodeTargetData }
|
||||
| null;
|
||||
|
||||
export interface RemoteEntity {
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
/**
|
||||
* 虚拟节点检查器
|
||||
* Virtual Node Inspector
|
||||
*
|
||||
* 显示 FGUI 等组件内部虚拟节点的只读属性
|
||||
* Displays read-only properties of virtual nodes from components like FGUI
|
||||
*/
|
||||
|
||||
import type { IVirtualNode } from '@esengine/editor-core';
|
||||
import { Box, Eye, EyeOff, Move, Maximize2, RotateCw, Palette, Type, Image, Square, Layers, MousePointer, Sliders } from 'lucide-react';
|
||||
import '../../../styles/VirtualNodeInspector.css';
|
||||
|
||||
interface VirtualNodeInspectorProps {
|
||||
parentEntityId: number;
|
||||
virtualNode: IVirtualNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format number to fixed decimal places
|
||||
* 格式化数字到固定小数位
|
||||
*/
|
||||
function formatNumber(value: number | undefined, decimals: number = 2): string {
|
||||
if (value === undefined || value === null) return '-';
|
||||
return value.toFixed(decimals);
|
||||
}
|
||||
|
||||
/**
|
||||
* Property row component
|
||||
* 属性行组件
|
||||
*/
|
||||
function PropertyRow({ label, value, icon }: { label: string; value: React.ReactNode; icon?: React.ReactNode }) {
|
||||
return (
|
||||
<div className="virtual-node-property-row">
|
||||
<span className="property-label">
|
||||
{icon && <span className="property-icon">{icon}</span>}
|
||||
{label}
|
||||
</span>
|
||||
<span className="property-value">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Section component
|
||||
* 分组组件
|
||||
*/
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="virtual-node-section">
|
||||
<div className="section-header">{title}</div>
|
||||
<div className="section-content">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Color swatch component for displaying colors
|
||||
* 颜色色块组件
|
||||
*/
|
||||
function ColorSwatch({ color }: { color: string }) {
|
||||
return (
|
||||
<span className="color-swatch-wrapper">
|
||||
<span
|
||||
className="color-swatch"
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
<span className="color-value">{color}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a property key is for common/transform properties
|
||||
* 检查属性键是否为公共/变换属性
|
||||
*/
|
||||
const COMMON_PROPS = new Set([
|
||||
'className', 'x', 'y', 'width', 'height', 'alpha', 'visible',
|
||||
'touchable', 'rotation', 'scaleX', 'scaleY', 'pivotX', 'pivotY', 'grayed'
|
||||
]);
|
||||
|
||||
/**
|
||||
* Property categories for type-specific display
|
||||
* 类型特定显示的属性分类
|
||||
*/
|
||||
const TYPE_SPECIFIC_SECTIONS: Record<string, { title: string; icon: React.ReactNode; props: string[] }> = {
|
||||
Graph: {
|
||||
title: '图形属性 | Graph',
|
||||
icon: <Square size={12} />,
|
||||
props: ['graphType', 'lineSize', 'lineColor', 'fillColor', 'cornerRadius', 'sides', 'startAngle']
|
||||
},
|
||||
Image: {
|
||||
title: '图像属性 | Image',
|
||||
icon: <Image size={12} />,
|
||||
props: ['color', 'flip', 'fillMethod', 'fillOrigin', 'fillClockwise', 'fillAmount']
|
||||
},
|
||||
TextField: {
|
||||
title: '文本属性 | Text',
|
||||
icon: <Type size={12} />,
|
||||
props: ['text', 'font', 'fontSize', 'color', 'align', 'valign', 'leading', 'letterSpacing',
|
||||
'bold', 'italic', 'underline', 'singleLine', 'autoSize', 'stroke', 'strokeColor']
|
||||
},
|
||||
Loader: {
|
||||
title: '加载器属性 | Loader',
|
||||
icon: <Image size={12} />,
|
||||
props: ['url', 'align', 'verticalAlign', 'fill', 'shrinkOnly', 'autoSize', 'color',
|
||||
'fillMethod', 'fillOrigin', 'fillClockwise', 'fillAmount']
|
||||
},
|
||||
Button: {
|
||||
title: '按钮属性 | Button',
|
||||
icon: <MousePointer size={12} />,
|
||||
props: ['title', 'icon', 'mode', 'selected', 'titleColor', 'titleFontSize',
|
||||
'selectedTitle', 'selectedIcon']
|
||||
},
|
||||
List: {
|
||||
title: '列表属性 | List',
|
||||
icon: <Layers size={12} />,
|
||||
props: ['defaultItem', 'itemCount', 'selectedIndex', 'scrollPane']
|
||||
},
|
||||
ProgressBar: {
|
||||
title: '进度条属性 | Progress',
|
||||
icon: <Sliders size={12} />,
|
||||
props: ['value', 'max']
|
||||
},
|
||||
Slider: {
|
||||
title: '滑块属性 | Slider',
|
||||
icon: <Sliders size={12} />,
|
||||
props: ['value', 'max']
|
||||
},
|
||||
Component: {
|
||||
title: '组件属性 | Component',
|
||||
icon: <Layers size={12} />,
|
||||
props: ['numChildren', 'numControllers', 'numTransitions']
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Format a property value for display
|
||||
* 格式化属性值以供显示
|
||||
*/
|
||||
function formatPropertyValue(key: string, value: unknown): React.ReactNode {
|
||||
if (value === null || value === undefined) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
// Color properties - show color swatch
|
||||
if (typeof value === 'string' && (
|
||||
key.toLowerCase().includes('color') ||
|
||||
key === 'fillColor' ||
|
||||
key === 'lineColor' ||
|
||||
key === 'strokeColor' ||
|
||||
key === 'titleColor'
|
||||
)) {
|
||||
if (value.startsWith('#') || value.startsWith('rgb')) {
|
||||
return <ColorSwatch color={value} />;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof value === 'boolean') {
|
||||
return value ? 'Yes' : 'No';
|
||||
}
|
||||
|
||||
if (typeof value === 'number') {
|
||||
return formatNumber(value);
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
// Truncate long strings
|
||||
if (value.length > 50) {
|
||||
return value.substring(0, 47) + '...';
|
||||
}
|
||||
return value || '-';
|
||||
}
|
||||
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
export function VirtualNodeInspector({ parentEntityId, virtualNode }: VirtualNodeInspectorProps) {
|
||||
const { name, type, visible, x, y, width, height, data } = virtualNode;
|
||||
|
||||
// Extract additional properties from data
|
||||
// 从 data 中提取额外属性
|
||||
const alpha = data.alpha as number | undefined;
|
||||
const rotation = data.rotation as number | undefined;
|
||||
const scaleX = data.scaleX as number | undefined;
|
||||
const scaleY = data.scaleY as number | undefined;
|
||||
const touchable = data.touchable as boolean | undefined;
|
||||
const grayed = data.grayed as boolean | undefined;
|
||||
const pivotX = data.pivotX as number | undefined;
|
||||
const pivotY = data.pivotY as number | undefined;
|
||||
|
||||
// Get type-specific section config
|
||||
const typeSection = TYPE_SPECIFIC_SECTIONS[type];
|
||||
|
||||
// Collect type-specific properties
|
||||
const typeSpecificProps: Array<{ key: string; value: unknown }> = [];
|
||||
const otherProps: Array<{ key: string; value: unknown }> = [];
|
||||
|
||||
Object.entries(data).forEach(([key, value]) => {
|
||||
if (COMMON_PROPS.has(key)) {
|
||||
return; // Skip common props
|
||||
}
|
||||
|
||||
if (typeSection?.props.includes(key)) {
|
||||
typeSpecificProps.push({ key, value });
|
||||
} else {
|
||||
otherProps.push({ key, value });
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="entity-inspector virtual-node-inspector">
|
||||
{/* Header */}
|
||||
<div className="virtual-node-header">
|
||||
<Box size={16} className="header-icon" />
|
||||
<div className="header-info">
|
||||
<div className="header-name">{name}</div>
|
||||
<div className="header-type">{type}</div>
|
||||
</div>
|
||||
<div className="header-badge">
|
||||
Virtual Node
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Read-only notice */}
|
||||
<div className="virtual-node-notice">
|
||||
此节点为只读,属性由运行时动态生成
|
||||
</div>
|
||||
|
||||
{/* Basic Properties */}
|
||||
<Section title="基本属性 | Basic">
|
||||
<PropertyRow
|
||||
label="Visible"
|
||||
value={visible ? <Eye size={14} /> : <EyeOff size={14} className="disabled" />}
|
||||
/>
|
||||
{touchable !== undefined && (
|
||||
<PropertyRow
|
||||
label="Touchable"
|
||||
value={touchable ? 'Yes' : 'No'}
|
||||
/>
|
||||
)}
|
||||
{grayed !== undefined && (
|
||||
<PropertyRow
|
||||
label="Grayed"
|
||||
value={grayed ? 'Yes' : 'No'}
|
||||
/>
|
||||
)}
|
||||
{alpha !== undefined && (
|
||||
<PropertyRow
|
||||
label="Alpha"
|
||||
value={formatNumber(alpha)}
|
||||
icon={<Palette size={12} />}
|
||||
/>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* Transform */}
|
||||
<Section title="变换 | Transform">
|
||||
<PropertyRow
|
||||
label="Position"
|
||||
value={`(${formatNumber(x)}, ${formatNumber(y)})`}
|
||||
icon={<Move size={12} />}
|
||||
/>
|
||||
<PropertyRow
|
||||
label="Size"
|
||||
value={`${formatNumber(width)} × ${formatNumber(height)}`}
|
||||
icon={<Maximize2 size={12} />}
|
||||
/>
|
||||
{(rotation !== undefined && rotation !== 0) && (
|
||||
<PropertyRow
|
||||
label="Rotation"
|
||||
value={`${formatNumber(rotation)}°`}
|
||||
icon={<RotateCw size={12} />}
|
||||
/>
|
||||
)}
|
||||
{(scaleX !== undefined || scaleY !== undefined) && (
|
||||
<PropertyRow
|
||||
label="Scale"
|
||||
value={`(${formatNumber(scaleX ?? 1)}, ${formatNumber(scaleY ?? 1)})`}
|
||||
/>
|
||||
)}
|
||||
{(pivotX !== undefined || pivotY !== undefined) && (
|
||||
<PropertyRow
|
||||
label="Pivot"
|
||||
value={`(${formatNumber(pivotX ?? 0)}, ${formatNumber(pivotY ?? 0)})`}
|
||||
/>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* Type-Specific Properties */}
|
||||
{typeSection && typeSpecificProps.length > 0 && (
|
||||
<Section title={typeSection.title}>
|
||||
{typeSpecificProps.map(({ key, value }) => (
|
||||
<PropertyRow
|
||||
key={key}
|
||||
label={key}
|
||||
value={formatPropertyValue(key, value)}
|
||||
icon={key === typeSection.props[0] ? typeSection.icon : undefined}
|
||||
/>
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Other Properties */}
|
||||
{otherProps.length > 0 && (
|
||||
<Section title="其他属性 | Other">
|
||||
{otherProps.map(({ key, value }) => (
|
||||
<PropertyRow
|
||||
key={key}
|
||||
label={key}
|
||||
value={formatPropertyValue(key, value)}
|
||||
/>
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Debug Info */}
|
||||
<Section title="调试信息 | Debug">
|
||||
<PropertyRow label="Parent Entity ID" value={parentEntityId} />
|
||||
<PropertyRow label="Virtual Node ID" value={virtualNode.id} />
|
||||
<PropertyRow label="Child Count" value={virtualNode.children?.length ?? 0} />
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,3 +4,4 @@ export { AssetFileInspector } from './AssetFileInspector';
|
||||
export { RemoteEntityInspector } from './RemoteEntityInspector';
|
||||
export { EntityInspector } from './EntityInspector';
|
||||
export { PrefabInspector } from './PrefabInspector';
|
||||
export { VirtualNodeInspector } from './VirtualNodeInspector';
|
||||
|
||||
Reference in New Issue
Block a user