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:
@@ -44,8 +44,8 @@
|
||||
"@esengine/sprite-editor": "workspace:*",
|
||||
"@esengine/tilemap": "workspace:*",
|
||||
"@esengine/tilemap-editor": "workspace:*",
|
||||
"@esengine/ui": "workspace:*",
|
||||
"@esengine/ui-editor": "workspace:*",
|
||||
"@esengine/fairygui": "workspace:*",
|
||||
"@esengine/fairygui-editor": "workspace:*",
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@tauri-apps/api": "^2.2.0",
|
||||
"@tauri-apps/plugin-cli": "^2.4.1",
|
||||
|
||||
@@ -525,3 +525,11 @@ pub async fn read_binary_file_as_base64(path: String) -> Result<String, String>
|
||||
|
||||
Ok(STANDARD.encode(&bytes))
|
||||
}
|
||||
|
||||
/// Read binary file and return as raw bytes.
|
||||
/// 读取二进制文件并返回原始字节。
|
||||
#[tauri::command]
|
||||
pub async fn read_binary_file(file_path: String) -> Result<Vec<u8>, String> {
|
||||
fs::read(&file_path)
|
||||
.map_err(|e| format!("Failed to read binary file | 读取二进制文件失败: {}", e))
|
||||
}
|
||||
|
||||
@@ -109,6 +109,7 @@ fn main() {
|
||||
commands::write_json_file,
|
||||
commands::list_files_by_extension,
|
||||
commands::read_binary_file_as_base64,
|
||||
commands::read_binary_file,
|
||||
// Engine modules | 引擎模块
|
||||
commands::read_engine_modules_index,
|
||||
commands::read_module_manifest,
|
||||
|
||||
@@ -22,7 +22,7 @@ import { BehaviorTreePlugin } from '@esengine/behavior-tree-editor';
|
||||
import { ParticlePlugin } from '@esengine/particle-editor';
|
||||
import { Physics2DPlugin } from '@esengine/physics-rapier2d-editor';
|
||||
import { TilemapPlugin } from '@esengine/tilemap-editor';
|
||||
import { UIPlugin } from '@esengine/ui-editor';
|
||||
import { FGUIPlugin } from '@esengine/fairygui-editor';
|
||||
import { BlueprintPlugin } from '@esengine/blueprint-editor';
|
||||
import { MaterialPlugin } from '@esengine/material-editor';
|
||||
import { SpritePlugin } from '@esengine/sprite-editor';
|
||||
@@ -63,7 +63,7 @@ export class PluginInstaller {
|
||||
{ name: 'CameraPlugin', plugin: CameraPlugin },
|
||||
{ name: 'SpritePlugin', plugin: SpritePlugin },
|
||||
{ name: 'TilemapPlugin', plugin: TilemapPlugin },
|
||||
{ name: 'UIPlugin', plugin: UIPlugin },
|
||||
{ name: 'FGUIPlugin', plugin: FGUIPlugin },
|
||||
{ name: 'BehaviorTreePlugin', plugin: BehaviorTreePlugin },
|
||||
{ name: 'ParticlePlugin', plugin: ParticlePlugin },
|
||||
{ name: 'Physics2DPlugin', plugin: Physics2DPlugin },
|
||||
|
||||
@@ -48,7 +48,7 @@ import { TransformComponent } from '@esengine/engine-core';
|
||||
import { SpriteComponent, SpriteAnimatorComponent } from '@esengine/sprite';
|
||||
import { CameraComponent } from '@esengine/camera';
|
||||
import { AudioSourceComponent } from '@esengine/audio';
|
||||
import { UITextComponent } from '@esengine/ui';
|
||||
import { FGUIComponent } from '@esengine/fairygui';
|
||||
import { BehaviorTreeRuntimeComponent } from '@esengine/behavior-tree';
|
||||
import { TauriFileAPI } from '../../adapters/TauriFileAPI';
|
||||
import { DIContainer } from '../../core/di/DIContainer';
|
||||
@@ -129,7 +129,7 @@ export class ServiceRegistry {
|
||||
{ name: 'TransformComponent', type: TransformComponent, editorName: 'Transform', category: 'components.category.core', description: 'components.transform.description', icon: 'Move3d' },
|
||||
{ name: 'SpriteComponent', type: SpriteComponent, editorName: 'Sprite', category: 'components.category.rendering', description: 'components.sprite.description', icon: 'Image' },
|
||||
{ name: 'SpriteAnimatorComponent', type: SpriteAnimatorComponent, editorName: 'SpriteAnimator', category: 'components.category.rendering', description: 'components.spriteAnimator.description', icon: 'Film' },
|
||||
{ name: 'UITextComponent', type: UITextComponent, editorName: 'UIText', category: 'components.category.ui', description: 'components.text.description', icon: 'Type' },
|
||||
{ name: 'FGUIComponent', type: FGUIComponent, editorName: 'FGUI', category: 'components.category.ui', description: 'FairyGUI UI component', icon: 'Layout' },
|
||||
{ name: 'CameraComponent', type: CameraComponent, editorName: 'Camera', category: 'components.category.rendering', description: 'components.camera.description', icon: 'Camera' },
|
||||
{ name: 'AudioSourceComponent', type: AudioSourceComponent, editorName: 'AudioSource', category: 'components.category.audio', description: 'components.audioSource.description', icon: 'Volume2' },
|
||||
{ name: 'BehaviorTreeRuntimeComponent', type: BehaviorTreeRuntimeComponent, editorName: 'BehaviorTreeRuntime', category: 'components.category.ai', description: 'components.behaviorTreeRuntime.description', icon: 'GitBranch' }
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Entity, Component } from '@esengine/ecs-framework';
|
||||
import { MessageHub } from '@esengine/editor-core';
|
||||
import { TransformComponent } from '@esengine/engine-core';
|
||||
import { UITransformComponent } from '@esengine/ui';
|
||||
import { BaseCommand } from '../BaseCommand';
|
||||
import { ICommand } from '../ICommand';
|
||||
|
||||
@@ -10,7 +9,6 @@ import { ICommand } from '../ICommand';
|
||||
* Transform state snapshot
|
||||
*/
|
||||
export interface TransformState {
|
||||
// TransformComponent
|
||||
positionX?: number;
|
||||
positionY?: number;
|
||||
positionZ?: number;
|
||||
@@ -20,14 +18,6 @@ export interface TransformState {
|
||||
scaleX?: number;
|
||||
scaleY?: number;
|
||||
scaleZ?: number;
|
||||
// UITransformComponent
|
||||
x?: number;
|
||||
y?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
rotation?: number;
|
||||
uiScaleX?: number;
|
||||
uiScaleY?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -41,19 +31,17 @@ export type TransformOperationType = 'move' | 'rotate' | 'scale';
|
||||
* Transform command for undo/redo support
|
||||
*/
|
||||
export class TransformCommand extends BaseCommand {
|
||||
private readonly componentType: 'transform' | 'uiTransform';
|
||||
private readonly timestamp: number;
|
||||
|
||||
constructor(
|
||||
private readonly messageHub: MessageHub,
|
||||
private readonly entity: Entity,
|
||||
private readonly component: Component,
|
||||
private readonly component: TransformComponent,
|
||||
private readonly operationType: TransformOperationType,
|
||||
private readonly oldState: TransformState,
|
||||
private newState: TransformState
|
||||
) {
|
||||
super();
|
||||
this.componentType = component instanceof TransformComponent ? 'transform' : 'uiTransform';
|
||||
this.timestamp = Date.now();
|
||||
}
|
||||
|
||||
@@ -114,25 +102,16 @@ export class TransformCommand extends BaseCommand {
|
||||
* Apply transform state
|
||||
*/
|
||||
private applyState(state: TransformState): void {
|
||||
if (this.componentType === 'transform') {
|
||||
const transform = this.component as TransformComponent;
|
||||
if (state.positionX !== undefined) transform.position.x = state.positionX;
|
||||
if (state.positionY !== undefined) transform.position.y = state.positionY;
|
||||
if (state.positionZ !== undefined) transform.position.z = state.positionZ;
|
||||
if (state.rotationX !== undefined) transform.rotation.x = state.rotationX;
|
||||
if (state.rotationY !== undefined) transform.rotation.y = state.rotationY;
|
||||
if (state.rotationZ !== undefined) transform.rotation.z = state.rotationZ;
|
||||
if (state.scaleX !== undefined) transform.scale.x = state.scaleX;
|
||||
if (state.scaleY !== undefined) transform.scale.y = state.scaleY;
|
||||
if (state.scaleZ !== undefined) transform.scale.z = state.scaleZ;
|
||||
} else {
|
||||
const uiTransform = this.component as UITransformComponent;
|
||||
if (state.x !== undefined) uiTransform.x = state.x;
|
||||
if (state.y !== undefined) uiTransform.y = state.y;
|
||||
if (state.rotation !== undefined) uiTransform.rotation = state.rotation;
|
||||
if (state.uiScaleX !== undefined) uiTransform.scaleX = state.uiScaleX;
|
||||
if (state.uiScaleY !== undefined) uiTransform.scaleY = state.uiScaleY;
|
||||
}
|
||||
const transform = this.component;
|
||||
if (state.positionX !== undefined) transform.position.x = state.positionX;
|
||||
if (state.positionY !== undefined) transform.position.y = state.positionY;
|
||||
if (state.positionZ !== undefined) transform.position.z = state.positionZ;
|
||||
if (state.rotationX !== undefined) transform.rotation.x = state.rotationX;
|
||||
if (state.rotationY !== undefined) transform.rotation.y = state.rotationY;
|
||||
if (state.rotationZ !== undefined) transform.rotation.z = state.rotationZ;
|
||||
if (state.scaleX !== undefined) transform.scale.x = state.scaleX;
|
||||
if (state.scaleY !== undefined) transform.scale.y = state.scaleY;
|
||||
if (state.scaleZ !== undefined) transform.scale.z = state.scaleZ;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -141,18 +120,16 @@ export class TransformCommand extends BaseCommand {
|
||||
*/
|
||||
private notifyChange(): void {
|
||||
const propertyName = this.operationType === 'move'
|
||||
? (this.componentType === 'transform' ? 'position' : 'x')
|
||||
? 'position'
|
||||
: this.operationType === 'rotate'
|
||||
? 'rotation'
|
||||
: (this.componentType === 'transform' ? 'scale' : 'scaleX');
|
||||
: 'scale';
|
||||
|
||||
this.messageHub.publish('component:property:changed', {
|
||||
entity: this.entity,
|
||||
component: this.component,
|
||||
propertyName,
|
||||
value: this.componentType === 'transform'
|
||||
? (this.component as TransformComponent)[propertyName as keyof TransformComponent]
|
||||
: (this.component as UITransformComponent)[propertyName as keyof UITransformComponent]
|
||||
value: this.component[propertyName as keyof TransformComponent]
|
||||
});
|
||||
|
||||
// 通知 Inspector 刷新 | Notify Inspector to refresh
|
||||
@@ -176,18 +153,4 @@ export class TransformCommand extends BaseCommand {
|
||||
scaleZ: transform.scale.z
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 UITransformComponent 捕获状态
|
||||
* Capture state from UITransformComponent
|
||||
*/
|
||||
static captureUITransformState(uiTransform: UITransformComponent): TransformState {
|
||||
return {
|
||||
x: uiTransform.x,
|
||||
y: uiTransform.y,
|
||||
rotation: uiTransform.rotation,
|
||||
uiScaleX: uiTransform.scaleX,
|
||||
uiScaleY: uiTransform.scaleY
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { Core, HierarchyComponent, PrefabInstanceComponent } from '@esengine/ecs-framework';
|
||||
import type { MessageHub, EntityStoreService, SceneManagerService } from '@esengine/editor-core';
|
||||
import { VirtualNodeRegistry } from '@esengine/editor-core';
|
||||
import { useHierarchyStore } from '../stores/HierarchyStore';
|
||||
import { useInspectorStore } from '../stores/InspectorStore';
|
||||
import { getProfilerService } from '../services/getService';
|
||||
@@ -285,6 +286,7 @@ export function useStoreSubscriptions({
|
||||
setRemoteEntityTarget,
|
||||
setAssetFileTarget,
|
||||
setExtensionTarget,
|
||||
setVirtualNodeTarget,
|
||||
clearTarget,
|
||||
updateRemoteEntityDetails,
|
||||
incrementComponentVersion,
|
||||
@@ -306,6 +308,9 @@ export function useStoreSubscriptions({
|
||||
|
||||
// 实体选择处理 | Handle entity selection
|
||||
const handleEntitySelection = (data: { entity: any | null }) => {
|
||||
// Clear virtual node selection when selecting an entity
|
||||
// 选择实体时清除虚拟节点选择
|
||||
VirtualNodeRegistry.clearSelectedVirtualNode();
|
||||
if (data.entity) {
|
||||
setEntityTarget(data.entity);
|
||||
} else {
|
||||
@@ -336,6 +341,18 @@ export function useStoreSubscriptions({
|
||||
setExtensionTarget(data.data as Record<string, unknown>);
|
||||
};
|
||||
|
||||
// 虚拟节点选择处理 | Handle virtual node selection
|
||||
const handleVirtualNodeSelection = (data: {
|
||||
parentEntityId: number;
|
||||
virtualNodeId: string;
|
||||
virtualNode: any;
|
||||
}) => {
|
||||
// Update VirtualNodeRegistry for Gizmo highlighting
|
||||
// 更新 VirtualNodeRegistry 用于 Gizmo 高亮
|
||||
VirtualNodeRegistry.setSelectedVirtualNode(data.parentEntityId, data.virtualNodeId);
|
||||
setVirtualNodeTarget(data.parentEntityId, data.virtualNodeId, data.virtualNode);
|
||||
};
|
||||
|
||||
// 资产文件选择处理 | Handle asset file selection
|
||||
const handleAssetFileSelection = async (data: { fileInfo: AssetFileInfo }) => {
|
||||
const fileInfo = data.fileInfo;
|
||||
@@ -382,6 +399,7 @@ export function useStoreSubscriptions({
|
||||
const unsubSceneRestored = messageHub.subscribe('scene:restored', handleSceneRestored);
|
||||
const unsubRemoteSelect = messageHub.subscribe('remote-entity:selected', handleRemoteEntitySelection);
|
||||
const unsubNodeSelect = messageHub.subscribe('behavior-tree:node-selected', handleExtensionSelection);
|
||||
const unsubVirtualNodeSelect = messageHub.subscribe('virtual-node:selected', handleVirtualNodeSelection);
|
||||
const unsubAssetFileSelect = messageHub.subscribe('asset-file:selected', handleAssetFileSelection);
|
||||
const unsubComponentAdded = messageHub.subscribe('component:added', incrementComponentVersion);
|
||||
const unsubComponentRemoved = messageHub.subscribe('component:removed', incrementComponentVersion);
|
||||
@@ -394,6 +412,7 @@ export function useStoreSubscriptions({
|
||||
unsubSceneRestored();
|
||||
unsubRemoteSelect();
|
||||
unsubNodeSelect();
|
||||
unsubVirtualNodeSelect();
|
||||
unsubAssetFileSelect();
|
||||
unsubComponentAdded();
|
||||
unsubComponentRemoved();
|
||||
|
||||
@@ -10,7 +10,7 @@ import type { ServiceContainer } from '@esengine/ecs-framework';
|
||||
import { createLogger, Core } from '@esengine/ecs-framework';
|
||||
import type { IEditorPlugin, IEditorModuleLoader, ModuleManifest } from '@esengine/editor-core';
|
||||
import { SettingsRegistry, ProjectService, moduleRegistry } from '@esengine/editor-core';
|
||||
import EngineService from '../../services/EngineService';
|
||||
import { EngineService } from '../../services/EngineService';
|
||||
|
||||
/**
|
||||
* Get engine modules from ModuleRegistry.
|
||||
@@ -37,6 +37,7 @@ export const UI_RESOLUTION_PRESETS = [
|
||||
{ label: '1920 x 1080 (Full HD)', value: { width: 1920, height: 1080 } },
|
||||
{ label: '1280 x 720 (HD)', value: { width: 1280, height: 720 } },
|
||||
{ label: '1366 x 768 (HD+)', value: { width: 1366, height: 768 } },
|
||||
{ label: '1136 x 640 (iPhone 5)', value: { width: 1136, height: 640 } },
|
||||
{ label: '2560 x 1440 (2K)', value: { width: 2560, height: 1440 } },
|
||||
{ label: '3840 x 2160 (4K)', value: { width: 3840, height: 2160 } },
|
||||
{ label: '750 x 1334 (iPhone 6/7/8)', value: { width: 750, height: 1334 } },
|
||||
@@ -137,74 +138,6 @@ class ProjectSettingsEditorModule implements IEditorModuleLoader {
|
||||
} as any // Cast to any to allow custom props
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'dynamic-atlas',
|
||||
title: '$pluginSettings.project.dynamicAtlas.title',
|
||||
description: '$pluginSettings.project.dynamicAtlas.description',
|
||||
settings: [
|
||||
{
|
||||
key: 'project.dynamicAtlas.enabled',
|
||||
label: '$pluginSettings.project.dynamicAtlas.enabled.label',
|
||||
type: 'boolean',
|
||||
defaultValue: true,
|
||||
description: '$pluginSettings.project.dynamicAtlas.enabled.description'
|
||||
},
|
||||
{
|
||||
key: 'project.dynamicAtlas.expansionStrategy',
|
||||
label: '$pluginSettings.project.dynamicAtlas.expansionStrategy.label',
|
||||
type: 'select',
|
||||
defaultValue: 'fixed',
|
||||
description: '$pluginSettings.project.dynamicAtlas.expansionStrategy.description',
|
||||
options: [
|
||||
{
|
||||
label: '$pluginSettings.project.dynamicAtlas.expansionStrategy.fixed',
|
||||
value: 'fixed'
|
||||
},
|
||||
{
|
||||
label: '$pluginSettings.project.dynamicAtlas.expansionStrategy.dynamic',
|
||||
value: 'dynamic'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'project.dynamicAtlas.fixedPageSize',
|
||||
label: '$pluginSettings.project.dynamicAtlas.fixedPageSize.label',
|
||||
type: 'select',
|
||||
defaultValue: 1024,
|
||||
description: '$pluginSettings.project.dynamicAtlas.fixedPageSize.description',
|
||||
options: [
|
||||
{ label: '512 x 512', value: 512 },
|
||||
{ label: '1024 x 1024', value: 1024 },
|
||||
{ label: '2048 x 2048', value: 2048 }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'project.dynamicAtlas.maxPages',
|
||||
label: '$pluginSettings.project.dynamicAtlas.maxPages.label',
|
||||
type: 'select',
|
||||
defaultValue: 4,
|
||||
description: '$pluginSettings.project.dynamicAtlas.maxPages.description',
|
||||
options: [
|
||||
{ label: '1', value: 1 },
|
||||
{ label: '2', value: 2 },
|
||||
{ label: '4', value: 4 },
|
||||
{ label: '8', value: 8 }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'project.dynamicAtlas.maxTextureSize',
|
||||
label: '$pluginSettings.project.dynamicAtlas.maxTextureSize.label',
|
||||
type: 'select',
|
||||
defaultValue: 512,
|
||||
description: '$pluginSettings.project.dynamicAtlas.maxTextureSize.description',
|
||||
options: [
|
||||
{ label: '256 x 256', value: 256 },
|
||||
{ label: '512 x 512', value: 512 },
|
||||
{ label: '1024 x 1024', value: 1024 }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
@@ -241,34 +174,11 @@ class ProjectSettingsEditorModule implements IEditorModuleLoader {
|
||||
this.applyUIDesignResolution();
|
||||
}
|
||||
|
||||
// Check if dynamic atlas settings changed
|
||||
// 检查动态图集设置是否更改
|
||||
if ('project.dynamicAtlas.enabled' in changedSettings ||
|
||||
'project.dynamicAtlas.expansionStrategy' in changedSettings ||
|
||||
'project.dynamicAtlas.fixedPageSize' in changedSettings ||
|
||||
'project.dynamicAtlas.maxPages' in changedSettings ||
|
||||
'project.dynamicAtlas.maxTextureSize' in changedSettings) {
|
||||
|
||||
logger.info('Dynamic atlas settings changed, reinitializing...');
|
||||
this.applyDynamicAtlasSettings();
|
||||
}
|
||||
}) as EventListener;
|
||||
|
||||
window.addEventListener('settings:changed', this.settingsListener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply dynamic atlas settings
|
||||
* 应用动态图集设置
|
||||
*/
|
||||
private applyDynamicAtlasSettings(): void {
|
||||
const engineService = EngineService.getInstance();
|
||||
if (engineService.isInitialized()) {
|
||||
engineService.reinitializeDynamicAtlas();
|
||||
logger.info('Dynamic atlas settings applied');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply UI design resolution from ProjectService
|
||||
* 从 ProjectService 应用 UI 设计分辨率
|
||||
|
||||
@@ -21,16 +21,14 @@ import { TransformComponent, TransformTypeToken, CanvasElementToken } from '@ese
|
||||
import { SpriteComponent, SpriteAnimatorComponent, SpriteAnimatorSystemToken } from '@esengine/sprite';
|
||||
import { ParticleSystemComponent } from '@esengine/particle';
|
||||
import {
|
||||
invalidateUIRenderCaches,
|
||||
UIRenderProviderToken,
|
||||
UIInputSystemToken,
|
||||
initializeDynamicAtlasService,
|
||||
reinitializeDynamicAtlasService,
|
||||
registerTexturePathMapping,
|
||||
AtlasExpansionStrategy,
|
||||
type IAtlasEngineBridge,
|
||||
type DynamicAtlasConfig
|
||||
} from '@esengine/ui';
|
||||
FGUIRenderSystemToken,
|
||||
getFGUIRenderSystem,
|
||||
FGUIRenderDataProvider,
|
||||
setGlobalTextureService,
|
||||
createTextureResolver,
|
||||
Stage,
|
||||
getDOMTextRenderer
|
||||
} from '@esengine/fairygui';
|
||||
import { SettingsService } from './SettingsService';
|
||||
import * as esEngine from '@esengine/engine';
|
||||
import {
|
||||
@@ -60,6 +58,7 @@ import { WebInputSubsystem } from '@esengine/platform-web';
|
||||
import { resetEngineState } from '../hooks/useEngine';
|
||||
import { convertFileSrc } from '@tauri-apps/api/core';
|
||||
import { IdGenerator } from '../utils/idGenerator';
|
||||
|
||||
import { TauriAssetReader } from './TauriAssetReader';
|
||||
|
||||
const logger = createLogger('EngineService');
|
||||
@@ -303,20 +302,106 @@ export class EngineService {
|
||||
const animatorSystem = services.get(SpriteAnimatorSystemToken);
|
||||
const behaviorTreeSystem = services.get(BehaviorTreeSystemToken);
|
||||
const physicsSystem = services.get(Physics2DSystemToken);
|
||||
const uiInputSystem = services.get(UIInputSystemToken);
|
||||
const uiRenderProvider = services.get(UIRenderProviderToken);
|
||||
const fguiRenderSystem = getFGUIRenderSystem();
|
||||
|
||||
if (animatorSystem) runtimeServices.register(SpriteAnimatorSystemToken, animatorSystem);
|
||||
if (behaviorTreeSystem) runtimeServices.register(BehaviorTreeSystemToken, behaviorTreeSystem);
|
||||
if (physicsSystem) runtimeServices.register(Physics2DSystemToken, physicsSystem);
|
||||
if (uiInputSystem) runtimeServices.register(UIInputSystemToken, uiInputSystem);
|
||||
if (uiRenderProvider) runtimeServices.register(UIRenderProviderToken, uiRenderProvider);
|
||||
if (fguiRenderSystem) runtimeServices.register(FGUIRenderSystemToken, fguiRenderSystem);
|
||||
}
|
||||
|
||||
// 设置 UI 渲染数据提供者
|
||||
const uiRenderProvider = services.get(UIRenderProviderToken);
|
||||
if (uiRenderProvider && this._runtime.renderSystem) {
|
||||
this._runtime.renderSystem.setUIRenderDataProvider(uiRenderProvider);
|
||||
// 设置 FairyGUI 渲染系统 | Set FairyGUI render system
|
||||
const fguiRenderSystem = getFGUIRenderSystem();
|
||||
const renderSystem = this._runtime?.renderSystem;
|
||||
if (fguiRenderSystem && this._runtime?.bridge && renderSystem) {
|
||||
const bridge = this._runtime.bridge;
|
||||
|
||||
// Set global texture service for FGUI
|
||||
// 设置 FGUI 的全局纹理服务
|
||||
setGlobalTextureService({
|
||||
loadTextureByPath: (url: string) => bridge.loadTextureByPath(url),
|
||||
getTextureIdByPath: (url: string) => bridge.getTextureIdByPath(url)
|
||||
});
|
||||
|
||||
// Create render data provider to convert FGUI primitives to engine format
|
||||
// 创建渲染数据提供者,将 FGUI 图元转换为引擎格式
|
||||
const fguiRenderDataProvider = new FGUIRenderDataProvider();
|
||||
fguiRenderDataProvider.setCollector(fguiRenderSystem.collector);
|
||||
fguiRenderDataProvider.setSorting('UI', 1000);
|
||||
|
||||
// Use the centralized texture resolver from FGUITextureManager
|
||||
// 使用 FGUITextureManager 的集中式纹理解析器
|
||||
fguiRenderDataProvider.setTextureResolver(createTextureResolver());
|
||||
|
||||
// Initialize DOM text renderer for text fallback
|
||||
// 初始化 DOM 文本渲染器作为文本回退
|
||||
const domTextRenderer = getDOMTextRenderer();
|
||||
const canvas = document.getElementById('viewport-canvas') as HTMLCanvasElement;
|
||||
if (canvas) {
|
||||
domTextRenderer.initialize(canvas);
|
||||
}
|
||||
|
||||
// Create UI render data provider adapter for EngineRenderSystem
|
||||
// 为 EngineRenderSystem 创建 UI 渲染数据提供者适配器
|
||||
// This adapter updates FGUI and returns render data in the format expected by the engine
|
||||
// 此适配器更新 FGUI 并以引擎期望的格式返回渲染数据
|
||||
const runtime = this._runtime;
|
||||
const uiRenderProvider = {
|
||||
getRenderData: () => {
|
||||
// Update canvas size for coordinate conversion
|
||||
// FGUI uses top-left origin, engine uses center origin
|
||||
// 更新画布尺寸用于坐标转换(FGUI 使用左上角原点,引擎使用中心原点)
|
||||
const canvasSize = renderSystem.getUICanvasSize();
|
||||
const canvasWidth = canvasSize.width > 0 ? canvasSize.width : 1920;
|
||||
const canvasHeight = canvasSize.height > 0 ? canvasSize.height : 1080;
|
||||
fguiRenderDataProvider.setCanvasSize(canvasWidth, canvasHeight);
|
||||
|
||||
// Update DOM text renderer settings
|
||||
// 更新 DOM 文本渲染器设置
|
||||
domTextRenderer.setDesignSize(canvasWidth, canvasHeight);
|
||||
domTextRenderer.setPreviewMode(renderSystem.isPreviewMode());
|
||||
|
||||
// In editor mode, sync camera state for world-space text rendering
|
||||
// 在编辑器模式下,同步相机状态以进行世界空间文本渲染
|
||||
if (!renderSystem.isPreviewMode() && runtime?.bridge) {
|
||||
const camera = runtime.bridge.getCamera();
|
||||
domTextRenderer.setCamera({
|
||||
x: camera.x,
|
||||
y: camera.y,
|
||||
zoom: camera.zoom,
|
||||
rotation: camera.rotation
|
||||
});
|
||||
}
|
||||
|
||||
// Update FGUI system to collect render primitives
|
||||
// 更新 FGUI 系统以收集渲染图元
|
||||
fguiRenderSystem.update();
|
||||
|
||||
// Render text using DOM (fallback until MSDF text is fully integrated)
|
||||
// 使用 DOM 渲染文本(作为回退,直到 MSDF 文本完全集成)
|
||||
domTextRenderer.beginFrame();
|
||||
domTextRenderer.renderPrimitives(fguiRenderSystem.collector.getPrimitives());
|
||||
domTextRenderer.endFrame();
|
||||
|
||||
// Get render data from provider
|
||||
// 从提供者获取渲染数据
|
||||
fguiRenderDataProvider.setCollector(fguiRenderSystem.collector);
|
||||
return fguiRenderDataProvider.getRenderData();
|
||||
},
|
||||
|
||||
getMeshRenderData: () => {
|
||||
// Get mesh render data for complex shapes (ellipses, polygons, etc.)
|
||||
// 获取复杂形状(椭圆、多边形等)的网格渲染数据
|
||||
return fguiRenderDataProvider.getMeshRenderData();
|
||||
}
|
||||
};
|
||||
|
||||
// Register with EngineRenderSystem
|
||||
// 注册到 EngineRenderSystem
|
||||
renderSystem.setUIRenderDataProvider(uiRenderProvider);
|
||||
|
||||
fguiRenderSystem.enabled = true;
|
||||
logger.info('FairyGUI render system connected to engine via UI render provider');
|
||||
}
|
||||
|
||||
// 在编辑器模式下,禁用游戏逻辑系统
|
||||
@@ -350,14 +435,10 @@ export class EngineService {
|
||||
pluginManager.clearSceneSystems();
|
||||
}
|
||||
|
||||
// 使用服务注册表获取 UI 输入系统
|
||||
// Use service registry to get UI input system
|
||||
const runtimeServices = this._runtime?.getServiceRegistry();
|
||||
if (runtimeServices) {
|
||||
const uiInputSystem = runtimeServices.get(UIInputSystemToken);
|
||||
if (uiInputSystem && uiInputSystem.unbind) {
|
||||
uiInputSystem.unbind();
|
||||
}
|
||||
// 清理 FairyGUI 渲染系统 | Clean up FairyGUI render system
|
||||
const fguiRenderSystem = getFGUIRenderSystem();
|
||||
if (fguiRenderSystem) {
|
||||
fguiRenderSystem.enabled = false;
|
||||
}
|
||||
|
||||
// 清理 viewport | Clear viewport
|
||||
@@ -509,17 +590,26 @@ export class EngineService {
|
||||
const pathTransformerFn = (path: string) => {
|
||||
if (!path.startsWith('http://') && !path.startsWith('https://') &&
|
||||
!path.startsWith('data:') && !path.startsWith('asset://')) {
|
||||
// Normalize path separators to forward slashes first
|
||||
// 首先将路径分隔符规范化为正斜杠
|
||||
path = path.replace(/\\/g, '/');
|
||||
|
||||
if (!path.startsWith('/') && !path.match(/^[a-zA-Z]:/)) {
|
||||
if (projectService && projectService.isProjectOpen()) {
|
||||
const projectInfo = projectService.getCurrentProject();
|
||||
if (projectInfo) {
|
||||
const projectPath = projectInfo.path;
|
||||
const separator = projectPath.includes('\\') ? '\\' : '/';
|
||||
path = `${projectPath}${separator}${path.replace(/\//g, separator)}`;
|
||||
// Normalize project path to forward slashes
|
||||
// 将项目路径规范化为正斜杠
|
||||
const projectPath = projectInfo.path.replace(/\\/g, '/');
|
||||
path = `${projectPath}/${path}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
return convertFileSrc(path);
|
||||
// Use convertFileSrc which handles the asset protocol correctly
|
||||
// 使用 convertFileSrc 正确处理 asset 协议
|
||||
const result = convertFileSrc(path);
|
||||
console.log(`[pathTransformer] ${path} -> ${result}`);
|
||||
return result;
|
||||
}
|
||||
return path;
|
||||
};
|
||||
@@ -599,58 +689,6 @@ export class EngineService {
|
||||
this._sceneResourceManager = new SceneResourceManager();
|
||||
this._sceneResourceManager.setResourceLoader(this._engineIntegration);
|
||||
|
||||
// 初始化动态图集服务(用于 UI 合批)
|
||||
// Initialize dynamic atlas service (for UI batching)
|
||||
const bridge = this._runtime.bridge;
|
||||
if (bridge.createBlankTexture && bridge.updateTextureRegion) {
|
||||
const atlasBridge: IAtlasEngineBridge = {
|
||||
createBlankTexture: (width: number, height: number) => {
|
||||
return bridge.createBlankTexture(width, height);
|
||||
},
|
||||
updateTextureRegion: (
|
||||
id: number,
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
pixels: Uint8Array
|
||||
) => {
|
||||
bridge.updateTextureRegion(id, x, y, width, height, pixels);
|
||||
}
|
||||
};
|
||||
|
||||
// 从设置中获取动态图集配置
|
||||
// Get dynamic atlas config from settings
|
||||
const settingsService = SettingsService.getInstance();
|
||||
const atlasEnabled = settingsService.get('project.dynamicAtlas.enabled', true);
|
||||
|
||||
if (atlasEnabled) {
|
||||
const strategyValue = settingsService.get<string>('project.dynamicAtlas.expansionStrategy', 'fixed');
|
||||
const expansionStrategy = strategyValue === 'dynamic'
|
||||
? AtlasExpansionStrategy.Dynamic
|
||||
: AtlasExpansionStrategy.Fixed;
|
||||
const fixedPageSize = settingsService.get('project.dynamicAtlas.fixedPageSize', 1024);
|
||||
const maxPages = settingsService.get('project.dynamicAtlas.maxPages', 4);
|
||||
const maxTextureSize = settingsService.get('project.dynamicAtlas.maxTextureSize', 512);
|
||||
|
||||
initializeDynamicAtlasService(atlasBridge, {
|
||||
expansionStrategy,
|
||||
initialPageSize: 256, // 动态模式起始大小 | Dynamic mode initial size
|
||||
fixedPageSize, // 固定模式页面大小 | Fixed mode page size
|
||||
maxPageSize: 2048, // 最大页面大小 | Max page size
|
||||
maxPages,
|
||||
maxTextureSize,
|
||||
padding: 1
|
||||
});
|
||||
}
|
||||
|
||||
// 注册纹理加载回调,当纹理加载时自动注册路径映射
|
||||
// Register texture load callback to register path mapping when textures load
|
||||
EngineIntegration.onTextureLoad((guid: string, path: string, _textureId: number) => {
|
||||
registerTexturePathMapping(guid, path);
|
||||
});
|
||||
}
|
||||
|
||||
const sceneManagerService = Core.services.tryResolve<SceneManagerService>(SceneManagerService);
|
||||
if (sceneManagerService) {
|
||||
sceneManagerService.setSceneResourceManager(this._sceneResourceManager);
|
||||
@@ -1178,9 +1216,16 @@ export class EngineService {
|
||||
|
||||
/**
|
||||
* Set UI canvas size for boundary display.
|
||||
* Also syncs with FGUI Stage design size for coordinate conversion.
|
||||
*
|
||||
* 设置 UI 画布尺寸用于边界显示,同时同步到 FGUI Stage 设计尺寸用于坐标转换
|
||||
*/
|
||||
setUICanvasSize(width: number, height: number): void {
|
||||
this._runtime?.setUICanvasSize(width, height);
|
||||
|
||||
// Sync to FGUI Stage design size for coordinate conversion
|
||||
// 同步到 FGUI Stage 设计尺寸用于坐标转换
|
||||
Stage.inst.setDesignSize(width, height);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1213,9 +1258,8 @@ export class EngineService {
|
||||
const success = this._runtime?.saveSceneSnapshot() ?? false;
|
||||
|
||||
if (success) {
|
||||
// 清除 UI 渲染缓存(因为纹理已被清除)
|
||||
// Clear UI render caches (since textures have been cleared)
|
||||
invalidateUIRenderCaches();
|
||||
// 场景快照保存成功
|
||||
// Scene snapshot saved successfully
|
||||
}
|
||||
|
||||
return success;
|
||||
@@ -1230,9 +1274,6 @@ export class EngineService {
|
||||
const success = await this._runtime.restoreSceneSnapshot();
|
||||
|
||||
if (success) {
|
||||
// 清除 UI 渲染缓存
|
||||
invalidateUIRenderCaches();
|
||||
|
||||
// Reset particle component textureIds before loading resources
|
||||
// 在加载资源前重置粒子组件的 textureId
|
||||
// This ensures ParticleUpdateSystem will reload textures
|
||||
@@ -1408,76 +1449,6 @@ export class EngineService {
|
||||
return this._runtime;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reinitialize dynamic atlas with current settings.
|
||||
* 使用当前设置重新初始化动态图集。
|
||||
*
|
||||
* Call this when dynamic atlas settings change to apply them.
|
||||
* 当动态图集设置更改时调用此方法以应用更改。
|
||||
*/
|
||||
reinitializeDynamicAtlas(): void {
|
||||
const bridge = this._runtime?.bridge;
|
||||
if (!bridge?.createBlankTexture || !bridge?.updateTextureRegion) {
|
||||
logger.warn('Dynamic atlas requires createBlankTexture and updateTextureRegion');
|
||||
return;
|
||||
}
|
||||
|
||||
const atlasBridge: IAtlasEngineBridge = {
|
||||
createBlankTexture: (width: number, height: number) => {
|
||||
return bridge.createBlankTexture!(width, height);
|
||||
},
|
||||
updateTextureRegion: (
|
||||
id: number,
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
pixels: Uint8Array
|
||||
) => {
|
||||
bridge.updateTextureRegion!(id, x, y, width, height, pixels);
|
||||
}
|
||||
};
|
||||
|
||||
// 从设置中获取动态图集配置
|
||||
// Get dynamic atlas config from settings
|
||||
const settingsService = SettingsService.getInstance();
|
||||
const atlasEnabled = settingsService.get('project.dynamicAtlas.enabled', true);
|
||||
|
||||
if (!atlasEnabled) {
|
||||
logger.info('Dynamic atlas is disabled');
|
||||
return;
|
||||
}
|
||||
|
||||
const strategyValue = settingsService.get<string>('project.dynamicAtlas.expansionStrategy', 'fixed');
|
||||
const expansionStrategy = strategyValue === 'dynamic'
|
||||
? AtlasExpansionStrategy.Dynamic
|
||||
: AtlasExpansionStrategy.Fixed;
|
||||
const fixedPageSize = settingsService.get('project.dynamicAtlas.fixedPageSize', 1024);
|
||||
const maxPages = settingsService.get('project.dynamicAtlas.maxPages', 4);
|
||||
const maxTextureSize = settingsService.get('project.dynamicAtlas.maxTextureSize', 512);
|
||||
|
||||
logger.info('Dynamic atlas settings read from SettingsService:', {
|
||||
strategyValue,
|
||||
expansionStrategy: expansionStrategy === AtlasExpansionStrategy.Dynamic ? 'dynamic' : 'fixed',
|
||||
fixedPageSize,
|
||||
maxPages,
|
||||
maxTextureSize
|
||||
});
|
||||
|
||||
const config: DynamicAtlasConfig = {
|
||||
expansionStrategy,
|
||||
initialPageSize: 256,
|
||||
fixedPageSize,
|
||||
maxPageSize: 2048,
|
||||
maxPages,
|
||||
maxTextureSize,
|
||||
padding: 1
|
||||
};
|
||||
|
||||
reinitializeDynamicAtlasService(atlasBridge, config);
|
||||
logger.info('Dynamic atlas reinitialized with config:', config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose engine resources.
|
||||
*/
|
||||
|
||||
@@ -10,7 +10,7 @@ import { Core, Entity } from '@esengine/ecs-framework';
|
||||
import { TransformComponent } from '@esengine/engine-core';
|
||||
import { SpriteComponent } from '@esengine/sprite';
|
||||
import { ParticleSystemComponent } from '@esengine/particle';
|
||||
import { UITransformComponent, UIRenderComponent, UITextComponent, getUIRenderCollector, type BatchDebugInfo, registerTexturePathMapping, getDynamicAtlasService } from '@esengine/ui';
|
||||
import { FGUIComponent, GRoot, GComponent } from '@esengine/fairygui';
|
||||
import { AssetRegistryService, ProjectService } from '@esengine/editor-core';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
|
||||
@@ -102,123 +102,41 @@ export interface ParticleDebugInfo {
|
||||
}
|
||||
|
||||
/**
|
||||
* UI 元素调试信息
|
||||
* UI element debug info
|
||||
* FairyGUI 元素调试信息
|
||||
* FairyGUI element debug info
|
||||
*/
|
||||
export interface UIDebugInfo {
|
||||
export interface FGUIDebugInfo {
|
||||
entityId: number;
|
||||
entityName: string;
|
||||
type: 'rect' | 'image' | 'text' | 'ninepatch' | 'circle' | 'rounded-rect' | 'unknown';
|
||||
packageName: string;
|
||||
componentName: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
worldX: number;
|
||||
worldY: number;
|
||||
rotation: number;
|
||||
visible: boolean;
|
||||
alpha: number;
|
||||
sortingLayer: string;
|
||||
orderInLayer: number;
|
||||
/** 层级深度(从根节点计算)| Hierarchy depth (from root) */
|
||||
depth: number;
|
||||
/** 世界层内顺序 = depth * 1000 + orderInLayer | World order in layer */
|
||||
worldOrderInLayer: number;
|
||||
textureGuid?: string;
|
||||
textureUrl?: string;
|
||||
backgroundColor?: string;
|
||||
text?: string;
|
||||
fontSize?: number;
|
||||
/** 材质/着色器 ID | Material/Shader ID */
|
||||
materialId: number;
|
||||
/** 着色器名称 | Shader name */
|
||||
shaderName: string;
|
||||
/** Shader uniform 覆盖值 | Shader uniform override values */
|
||||
uniforms: Record<string, UniformDebugValue>;
|
||||
/** 顶点属性: 宽高比 (width/height) | Vertex attribute: aspect ratio */
|
||||
aspectRatio: number;
|
||||
/** 子对象数量 | Child count */
|
||||
childCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染调试快照
|
||||
* Render debug snapshot
|
||||
*/
|
||||
/**
|
||||
* 图集条目调试信息
|
||||
* Atlas entry debug info
|
||||
*/
|
||||
export interface AtlasEntryDebugInfo {
|
||||
/** 纹理 GUID | Texture GUID */
|
||||
guid: string;
|
||||
/** 在图集中的位置 | Position in atlas */
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
/** UV 坐标 | UV coordinates */
|
||||
uv: [number, number, number, number];
|
||||
/** 纹理图像 data URL(用于预览)| Texture image data URL (for preview) */
|
||||
dataUrl?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 图集页面调试信息
|
||||
* Atlas page debug info
|
||||
*/
|
||||
export interface AtlasPageDebugInfo {
|
||||
/** 页面索引 | Page index */
|
||||
pageIndex: number;
|
||||
/** 纹理 ID | Texture ID */
|
||||
textureId: number;
|
||||
/** 页面尺寸 | Page size */
|
||||
width: number;
|
||||
height: number;
|
||||
/** 占用率 | Occupancy */
|
||||
occupancy: number;
|
||||
/** 此页面中的条目 | Entries in this page */
|
||||
entries: AtlasEntryDebugInfo[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 动态图集统计信息
|
||||
* Dynamic atlas statistics
|
||||
*/
|
||||
export interface AtlasStats {
|
||||
/** 是否启用 | Whether enabled */
|
||||
enabled: boolean;
|
||||
/** 图集页数 | Number of atlas pages */
|
||||
pageCount: number;
|
||||
/** 已加入图集的纹理数 | Number of textures in atlas */
|
||||
textureCount: number;
|
||||
/** 平均占用率 | Average occupancy */
|
||||
averageOccupancy: number;
|
||||
/** 正在加载的纹理数 | Number of loading textures */
|
||||
loadingCount: number;
|
||||
/** 加载失败的纹理数 | Number of failed textures */
|
||||
failedCount: number;
|
||||
/** 每个页面的详细信息 | Detailed info for each page */
|
||||
pages: AtlasPageDebugInfo[];
|
||||
}
|
||||
|
||||
export interface RenderDebugSnapshot {
|
||||
timestamp: number;
|
||||
frameNumber: number;
|
||||
textures: TextureDebugInfo[];
|
||||
sprites: SpriteDebugInfo[];
|
||||
particles: ParticleDebugInfo[];
|
||||
uiElements: UIDebugInfo[];
|
||||
/** UI 合批调试信息 | UI batch debug info */
|
||||
uiBatches: BatchDebugInfo[];
|
||||
/** 动态图集统计 | Dynamic atlas stats */
|
||||
atlasStats?: AtlasStats;
|
||||
fguiElements: FGUIDebugInfo[];
|
||||
stats: {
|
||||
totalSprites: number;
|
||||
totalParticles: number;
|
||||
totalUIElements: number;
|
||||
totalFGUIElements: number;
|
||||
totalTextures: number;
|
||||
drawCalls: number;
|
||||
/** UI 批次数 | UI batch count */
|
||||
uiBatchCount: number;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -374,12 +292,6 @@ export class RenderDebugService {
|
||||
const dataUrl = `data:${mimeType};base64,${base64}`;
|
||||
|
||||
this._textureCache.set(textureGuid, dataUrl);
|
||||
|
||||
// 注册 GUID 到 data URL 映射(用于动态图集)
|
||||
// Register GUID to data URL mapping (for dynamic atlas)
|
||||
if (isGuid) {
|
||||
registerTexturePathMapping(textureGuid, dataUrl);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[RenderDebugService] Failed to load texture:', textureGuid, err);
|
||||
} finally {
|
||||
@@ -399,82 +311,28 @@ export class RenderDebugService {
|
||||
|
||||
this._frameNumber++;
|
||||
|
||||
// 收集 UI 合批信息 | Collect UI batch info
|
||||
const uiCollector = getUIRenderCollector();
|
||||
const uiBatches = [...uiCollector.getBatchDebugInfo()];
|
||||
|
||||
// 收集动态图集统计 | Collect dynamic atlas stats
|
||||
const atlasService = getDynamicAtlasService();
|
||||
let atlasStats: AtlasStats | undefined;
|
||||
if (atlasService) {
|
||||
const stats = atlasService.getStats();
|
||||
const pageDetails = atlasService.getPageDetails();
|
||||
|
||||
// 转换页面详细信息 | Convert page details
|
||||
const pages: AtlasPageDebugInfo[] = pageDetails.map(page => ({
|
||||
pageIndex: page.pageIndex,
|
||||
textureId: page.textureId,
|
||||
width: page.width,
|
||||
height: page.height,
|
||||
occupancy: page.occupancy,
|
||||
entries: page.entries.map(e => ({
|
||||
guid: e.guid,
|
||||
x: e.entry.region.x,
|
||||
y: e.entry.region.y,
|
||||
width: e.entry.region.width,
|
||||
height: e.entry.region.height,
|
||||
uv: e.entry.uv,
|
||||
// 从纹理缓存获取 data URL | Get data URL from texture cache
|
||||
dataUrl: this._textureCache.get(e.guid)
|
||||
}))
|
||||
}));
|
||||
|
||||
atlasStats = {
|
||||
enabled: true,
|
||||
pageCount: stats.pageCount,
|
||||
textureCount: stats.textureCount,
|
||||
averageOccupancy: stats.averageOccupancy,
|
||||
loadingCount: stats.loadingCount,
|
||||
failedCount: stats.failedCount,
|
||||
pages
|
||||
};
|
||||
} else {
|
||||
atlasStats = {
|
||||
enabled: false,
|
||||
pageCount: 0,
|
||||
textureCount: 0,
|
||||
averageOccupancy: 0,
|
||||
loadingCount: 0,
|
||||
failedCount: 0,
|
||||
pages: []
|
||||
};
|
||||
}
|
||||
|
||||
const snapshot: RenderDebugSnapshot = {
|
||||
timestamp: Date.now(),
|
||||
frameNumber: this._frameNumber,
|
||||
textures: this._collectTextures(),
|
||||
sprites: this._collectSprites(scene.entities.buffer),
|
||||
particles: this._collectParticles(scene.entities.buffer),
|
||||
uiElements: this._collectUI(scene.entities.buffer),
|
||||
uiBatches,
|
||||
atlasStats,
|
||||
fguiElements: this._collectFGUI(scene.entities.buffer),
|
||||
stats: {
|
||||
totalSprites: 0,
|
||||
totalParticles: 0,
|
||||
totalUIElements: 0,
|
||||
totalFGUIElements: 0,
|
||||
totalTextures: 0,
|
||||
drawCalls: 0,
|
||||
uiBatchCount: uiBatches.length,
|
||||
},
|
||||
};
|
||||
|
||||
// 计算统计 | Calculate stats
|
||||
snapshot.stats.totalSprites = snapshot.sprites.length;
|
||||
snapshot.stats.totalParticles = snapshot.particles.reduce((sum, p) => sum + p.activeCount, 0);
|
||||
snapshot.stats.totalUIElements = snapshot.uiElements.length;
|
||||
snapshot.stats.totalFGUIElements = snapshot.fguiElements.length;
|
||||
snapshot.stats.totalTextures = snapshot.textures.length;
|
||||
snapshot.stats.drawCalls = uiBatches.length; // UI batches as draw calls
|
||||
snapshot.stats.drawCalls = snapshot.sprites.length + snapshot.particles.length + snapshot.fguiElements.length;
|
||||
|
||||
// 保存快照 | Save snapshot
|
||||
this._snapshots.push(snapshot);
|
||||
@@ -673,97 +531,36 @@ export class RenderDebugService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 收集 UI 元素信息
|
||||
* Collect UI element info
|
||||
* 收集 FairyGUI 元素信息
|
||||
* Collect FairyGUI element info
|
||||
*/
|
||||
private _collectUI(entities: readonly Entity[]): UIDebugInfo[] {
|
||||
const uiElements: UIDebugInfo[] = [];
|
||||
private _collectFGUI(entities: readonly Entity[]): FGUIDebugInfo[] {
|
||||
const fguiElements: FGUIDebugInfo[] = [];
|
||||
|
||||
for (const entity of entities) {
|
||||
const uiTransform = entity.getComponent(UITransformComponent);
|
||||
const fguiComp = entity.getComponent(FGUIComponent) as FGUIComponent | null;
|
||||
|
||||
if (!uiTransform) continue;
|
||||
if (!fguiComp) continue;
|
||||
|
||||
const uiRender = entity.getComponent(UIRenderComponent);
|
||||
const uiText = entity.getComponent(UITextComponent);
|
||||
const root = fguiComp.root;
|
||||
const displayObject = root as GComponent | null;
|
||||
|
||||
// 确定类型 | Determine type
|
||||
let type: UIDebugInfo['type'] = 'unknown';
|
||||
if (uiText) {
|
||||
type = 'text';
|
||||
} else if (uiRender) {
|
||||
switch (uiRender.type) {
|
||||
case 'rect': type = 'rect'; break;
|
||||
case 'image': type = 'image'; break;
|
||||
case 'ninepatch': type = 'ninepatch'; break;
|
||||
case 'circle': type = 'circle'; break;
|
||||
case 'rounded-rect': type = 'rounded-rect'; break;
|
||||
default: type = 'rect';
|
||||
}
|
||||
}
|
||||
|
||||
// 获取纹理 GUID | Get texture GUID
|
||||
const textureGuid = uiRender?.textureGuid?.toString() ?? '';
|
||||
|
||||
// 转换颜色为十六进制字符串 | Convert color to hex string
|
||||
const backgroundColor = uiRender?.backgroundColor !== undefined
|
||||
? `#${uiRender.backgroundColor.toString(16).padStart(6, '0')}`
|
||||
: undefined;
|
||||
|
||||
// 获取材质/着色器 ID | Get material/shader ID
|
||||
const materialId = uiRender?.getMaterialId?.() ?? 0;
|
||||
|
||||
// 收集 uniform 覆盖值 | Collect uniform override values
|
||||
const uniforms: Record<string, UniformDebugValue> = {};
|
||||
const overrides = uiRender?.materialOverrides ?? {};
|
||||
for (const [name, override] of Object.entries(overrides)) {
|
||||
uniforms[name] = {
|
||||
type: override.type,
|
||||
value: override.value
|
||||
};
|
||||
}
|
||||
|
||||
// 计算 aspectRatio (与 Rust 端一致: width / height)
|
||||
// Calculate aspectRatio (same as Rust side: width / height)
|
||||
const uiWidth = uiTransform.width * (uiTransform.scaleX ?? 1);
|
||||
const uiHeight = uiTransform.height * (uiTransform.scaleY ?? 1);
|
||||
const aspectRatio = Math.abs(uiHeight) > 0.001 ? uiWidth / uiHeight : 1.0;
|
||||
|
||||
// 获取世界层内顺序并计算层级深度 | Get world order in layer and compute depth
|
||||
// worldOrderInLayer = depth * 1000 + orderInLayer
|
||||
const worldOrderInLayer = uiTransform.worldOrderInLayer ?? uiTransform.orderInLayer;
|
||||
const depth = Math.floor(worldOrderInLayer / 1000);
|
||||
|
||||
uiElements.push({
|
||||
fguiElements.push({
|
||||
entityId: entity.id,
|
||||
entityName: entity.name,
|
||||
type,
|
||||
x: uiTransform.x,
|
||||
y: uiTransform.y,
|
||||
width: uiTransform.width,
|
||||
height: uiTransform.height,
|
||||
worldX: uiTransform.worldX,
|
||||
worldY: uiTransform.worldY,
|
||||
rotation: uiTransform.rotation,
|
||||
visible: uiTransform.visible && uiTransform.worldVisible,
|
||||
alpha: uiTransform.worldAlpha,
|
||||
sortingLayer: uiTransform.sortingLayer,
|
||||
orderInLayer: uiTransform.orderInLayer,
|
||||
depth,
|
||||
worldOrderInLayer,
|
||||
textureGuid: textureGuid || undefined,
|
||||
textureUrl: textureGuid ? this._resolveTextureUrl(textureGuid) : undefined,
|
||||
backgroundColor,
|
||||
text: uiText?.text,
|
||||
fontSize: uiText?.fontSize,
|
||||
materialId,
|
||||
shaderName: getShaderName(materialId),
|
||||
uniforms,
|
||||
aspectRatio,
|
||||
packageName: fguiComp.packageGuid ?? '',
|
||||
componentName: fguiComp.componentName ?? '',
|
||||
x: displayObject?.x ?? 0,
|
||||
y: displayObject?.y ?? 0,
|
||||
width: displayObject?.width ?? 0,
|
||||
height: displayObject?.height ?? 0,
|
||||
visible: displayObject?.visible ?? true,
|
||||
alpha: displayObject?.alpha ?? 1,
|
||||
childCount: displayObject?.numChildren ?? 0,
|
||||
});
|
||||
}
|
||||
|
||||
return uiElements;
|
||||
return fguiElements;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -805,8 +602,3 @@ export class RenderDebugService {
|
||||
|
||||
// 全局实例 | Global instance
|
||||
export const renderDebugService = RenderDebugService.getInstance();
|
||||
|
||||
// 导出到全局以便控制台使用 | Export to global for console usage
|
||||
if (typeof window !== 'undefined') {
|
||||
(window as any).renderDebugService = renderDebugService;
|
||||
}
|
||||
|
||||
@@ -7,12 +7,20 @@
|
||||
*/
|
||||
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { convertFileSrc } from '@tauri-apps/api/core';
|
||||
import type { IAssetReader } from '@esengine/asset-system';
|
||||
|
||||
/** Blob URL cache to avoid re-reading files | Blob URL 缓存避免重复读取文件 */
|
||||
const blobUrlCache = new Map<string, string>();
|
||||
|
||||
/**
|
||||
* Asset reader implementation for Tauri.
|
||||
* Tauri 的资产读取器实现。
|
||||
*
|
||||
* Uses Tauri backend commands to read files and creates Blob URLs for images.
|
||||
* This approach works reliably with WebGL/Canvas without protocol restrictions.
|
||||
*
|
||||
* 使用 Tauri 后端命令读取文件,并为图片创建 Blob URL。
|
||||
* 这种方法在 WebGL/Canvas 中可靠工作,没有协议限制。
|
||||
*/
|
||||
export class TauriAssetReader implements IAssetReader {
|
||||
/**
|
||||
@@ -33,31 +41,71 @@ export class TauriAssetReader implements IAssetReader {
|
||||
}
|
||||
|
||||
/**
|
||||
* Load image from file.
|
||||
* 从文件加载图片。
|
||||
* Load image from file via backend.
|
||||
* 通过后端从文件加载图片。
|
||||
*
|
||||
* Reads binary data via Tauri backend and creates a Blob URL.
|
||||
* This bypasses browser protocol restrictions (asset://, file://).
|
||||
*
|
||||
* 通过 Tauri 后端读取二进制数据并创建 Blob URL。
|
||||
* 这绕过了浏览器协议限制。
|
||||
*/
|
||||
async loadImage(absolutePath: string): Promise<HTMLImageElement> {
|
||||
// Only convert if not already a URL.
|
||||
// 仅当不是 URL 时才转换。
|
||||
let assetUrl = absolutePath;
|
||||
if (!absolutePath.startsWith('http://') &&
|
||||
!absolutePath.startsWith('https://') &&
|
||||
!absolutePath.startsWith('data:') &&
|
||||
!absolutePath.startsWith('asset://')) {
|
||||
assetUrl = convertFileSrc(absolutePath);
|
||||
// Return cached if available
|
||||
let blobUrl = blobUrlCache.get(absolutePath);
|
||||
|
||||
if (!blobUrl) {
|
||||
// Read binary via backend
|
||||
const bytes = await invoke<number[]>('read_binary_file', { filePath: absolutePath });
|
||||
const data = new Uint8Array(bytes);
|
||||
|
||||
// Determine MIME type from extension
|
||||
const ext = absolutePath.toLowerCase().split('.').pop();
|
||||
let mimeType = 'image/png';
|
||||
if (ext === 'jpg' || ext === 'jpeg') mimeType = 'image/jpeg';
|
||||
else if (ext === 'gif') mimeType = 'image/gif';
|
||||
else if (ext === 'webp') mimeType = 'image/webp';
|
||||
|
||||
// Create Blob URL
|
||||
const blob = new Blob([data], { type: mimeType });
|
||||
blobUrl = URL.createObjectURL(blob);
|
||||
blobUrlCache.set(absolutePath, blobUrl);
|
||||
}
|
||||
|
||||
// Load image from Blob URL
|
||||
return new Promise((resolve, reject) => {
|
||||
const image = new Image();
|
||||
// 允许跨域访问,防止 canvas 被污染
|
||||
// Allow cross-origin access to prevent canvas tainting
|
||||
image.crossOrigin = 'anonymous';
|
||||
image.onload = () => resolve(image);
|
||||
image.onerror = () => reject(new Error(`Failed to load image: ${absolutePath}`));
|
||||
image.src = assetUrl;
|
||||
image.src = blobUrl!;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Blob URL for a file (for engine texture loading).
|
||||
* 获取文件的 Blob URL(用于引擎纹理加载)。
|
||||
*/
|
||||
async getBlobUrl(absolutePath: string): Promise<string> {
|
||||
let blobUrl = blobUrlCache.get(absolutePath);
|
||||
|
||||
if (!blobUrl) {
|
||||
const bytes = await invoke<number[]>('read_binary_file', { filePath: absolutePath });
|
||||
const data = new Uint8Array(bytes);
|
||||
|
||||
const ext = absolutePath.toLowerCase().split('.').pop();
|
||||
let mimeType = 'image/png';
|
||||
if (ext === 'jpg' || ext === 'jpeg') mimeType = 'image/jpeg';
|
||||
else if (ext === 'gif') mimeType = 'image/gif';
|
||||
else if (ext === 'webp') mimeType = 'image/webp';
|
||||
|
||||
const blob = new Blob([data], { type: mimeType });
|
||||
blobUrl = URL.createObjectURL(blob);
|
||||
blobUrlCache.set(absolutePath, blobUrl);
|
||||
}
|
||||
|
||||
return blobUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load audio from file.
|
||||
* 从文件加载音频。
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
import { create } from 'zustand';
|
||||
import { subscribeWithSelector } from 'zustand/middleware';
|
||||
import type { Entity } from '@esengine/ecs-framework';
|
||||
import type { InspectorTarget, AssetFileInfo, RemoteEntity, EntityDetails } from '../components/inspectors/types';
|
||||
import type { IVirtualNode } from '@esengine/editor-core';
|
||||
import type { InspectorTarget, AssetFileInfo, RemoteEntity, EntityDetails, VirtualNodeTargetData } from '../components/inspectors/types';
|
||||
|
||||
// ============= Types =============
|
||||
|
||||
@@ -45,6 +46,8 @@ export interface InspectorActions {
|
||||
setAssetFileTarget: (fileInfo: AssetFileInfo, content?: string, isImage?: boolean) => void;
|
||||
/** 设置扩展目标 | Set extension target */
|
||||
setExtensionTarget: (data: Record<string, unknown>) => void;
|
||||
/** 设置虚拟节点目标 | Set virtual node target */
|
||||
setVirtualNodeTarget: (parentEntityId: number, virtualNodeId: string, virtualNode: IVirtualNode) => void;
|
||||
/** 清除目标 | Clear target */
|
||||
clearTarget: () => void;
|
||||
/** 更新远程实体详情 | Update remote entity details */
|
||||
@@ -118,6 +121,17 @@ export const useInspectorStore = create<InspectorStore>()(
|
||||
});
|
||||
},
|
||||
|
||||
setVirtualNodeTarget: (parentEntityId, virtualNodeId, virtualNode) => {
|
||||
// 锁定时忽略 | Ignore when locked
|
||||
if (get().isLocked) return;
|
||||
set({
|
||||
target: {
|
||||
type: 'virtual-node',
|
||||
data: { parentEntityId, virtualNodeId, virtualNode }
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
clearTarget: () => {
|
||||
// 锁定时忽略 | Ignore when locked
|
||||
if (get().isLocked) return;
|
||||
|
||||
@@ -795,3 +795,49 @@
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ==================== Virtual Nodes | 虚拟节点 ==================== */
|
||||
/* Virtual nodes are read-only internal nodes from components like FGUI */
|
||||
/* 虚拟节点是来自组件(如 FGUI)的只读内部节点 */
|
||||
|
||||
.outliner-item.virtual-node {
|
||||
background: rgba(245, 158, 11, 0.05);
|
||||
border-left: 2px solid rgba(245, 158, 11, 0.4);
|
||||
}
|
||||
|
||||
.outliner-item.virtual-node:hover {
|
||||
background: rgba(245, 158, 11, 0.1);
|
||||
}
|
||||
|
||||
.outliner-item.virtual-node.selected {
|
||||
background: rgba(245, 158, 11, 0.2);
|
||||
border-left-color: #f59e0b;
|
||||
}
|
||||
|
||||
.outliner-item.virtual-node .outliner-item-name.virtual-name {
|
||||
color: #fbbf24;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.outliner-item.virtual-node .outliner-item-type.virtual-type {
|
||||
color: #d97706;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
/* Hidden virtual node (invisible in UI) */
|
||||
/* 隐藏的虚拟节点(在 UI 中不可见) */
|
||||
.outliner-item.virtual-node.hidden-node {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.outliner-item.virtual-node.hidden-node .outliner-item-name {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
/* Virtual node icon colors */
|
||||
/* 虚拟节点图标颜色 */
|
||||
.outliner-item.virtual-node svg {
|
||||
color: #f59e0b;
|
||||
flex-shrink: 0;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
165
packages/editor-app/src/styles/VirtualNodeInspector.css
Normal file
165
packages/editor-app/src/styles/VirtualNodeInspector.css
Normal file
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* 虚拟节点检查器样式
|
||||
* Virtual Node Inspector styles
|
||||
*/
|
||||
|
||||
.virtual-node-inspector {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background-color: #262626;
|
||||
color: #ccc;
|
||||
font-size: 11px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.virtual-node-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
background: linear-gradient(to right, rgba(245, 158, 11, 0.1), transparent);
|
||||
border-bottom: 1px solid #3a3a3a;
|
||||
border-left: 3px solid #f59e0b;
|
||||
}
|
||||
|
||||
.virtual-node-header .header-icon {
|
||||
color: #f59e0b;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.virtual-node-header .header-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.virtual-node-header .header-name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.virtual-node-header .header-type {
|
||||
font-size: 10px;
|
||||
color: #888;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.virtual-node-header .header-badge {
|
||||
font-size: 9px;
|
||||
padding: 2px 6px;
|
||||
background: rgba(245, 158, 11, 0.2);
|
||||
border: 1px solid rgba(245, 158, 11, 0.4);
|
||||
border-radius: 3px;
|
||||
color: #f59e0b;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Read-only notice */
|
||||
.virtual-node-notice {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
border-bottom: 1px solid #3a3a3a;
|
||||
color: #60a5fa;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
/* Section */
|
||||
.virtual-node-section {
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.virtual-node-section .section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
background: #2d2d2d;
|
||||
border-top: 1px solid #1a1a1a;
|
||||
border-bottom: 1px solid #1a1a1a;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: #888;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.virtual-node-section .section-content {
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
/* Property Row */
|
||||
.virtual-node-property-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 4px 12px;
|
||||
min-height: 24px;
|
||||
}
|
||||
|
||||
.virtual-node-property-row:hover {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.virtual-node-property-row .property-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: 100px;
|
||||
flex-shrink: 0;
|
||||
color: #999;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.virtual-node-property-row .property-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.virtual-node-property-row .property-value {
|
||||
flex: 1;
|
||||
color: #ccc;
|
||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.virtual-node-property-row .property-value svg {
|
||||
color: #4ade80;
|
||||
}
|
||||
|
||||
.virtual-node-property-row .property-value svg.disabled {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* Color swatch */
|
||||
.color-swatch-wrapper {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.color-swatch {
|
||||
display: inline-block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.3);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.color-value {
|
||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
font-size: 10px;
|
||||
color: #888;
|
||||
}
|
||||
Reference in New Issue
Block a user