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:
YHH
2025-12-22 10:52:54 +08:00
committed by GitHub
parent 96b5403d14
commit a1e1189f9d
237 changed files with 30983 additions and 23563 deletions

View File

@@ -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>
)

View File

@@ -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>

View File

@@ -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

View File

@@ -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;
}

View File

@@ -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 {

View File

@@ -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>
);
}

View File

@@ -4,3 +4,4 @@ export { AssetFileInspector } from './AssetFileInspector';
export { RemoteEntityInspector } from './RemoteEntityInspector';
export { EntityInspector } from './EntityInspector';
export { PrefabInspector } from './PrefabInspector';
export { VirtualNodeInspector } from './VirtualNodeInspector';