feat(fairygui): FairyGUI 完整集成 (#314)

* feat(fairygui): FairyGUI ECS 集成核心架构

实现 FairyGUI 的 ECS 原生集成,完全替代旧 UI 系统:

核心类:
- GObject: UI 对象基类,支持变换、可见性、关联、齿轮
- GComponent: 容器组件,管理子对象和控制器
- GRoot: 根容器,管理焦点、弹窗、输入分发
- GGroup: 组容器,支持水平/垂直布局

抽象层:
- DisplayObject: 显示对象基类
- EventDispatcher: 事件分发
- Timer: 计时器
- Stage: 舞台,管理输入和缩放

布局系统:
- Relations: 约束关联管理
- RelationItem: 24 种关联类型

基础设施:
- Controller: 状态控制器
- Transition: 过渡动画
- ScrollPane: 滚动面板
- UIPackage: 包管理
- ByteBuffer: 二进制解析

* refactor(ui): 删除旧 UI 系统,使用 FairyGUI 替代

* feat(fairygui): 实现 UI 控件

- 添加显示类:Image、TextField、Graph
- 添加基础控件:GImage、GTextField、GGraph
- 添加交互控件:GButton、GProgressBar、GSlider
- 更新 IRenderCollector 支持 Graph 渲染
- 扩展 Controller 添加 selectedPageId
- 添加 STATE_CHANGED 事件类型

* feat(fairygui): 现代化架构重构

- 增强 EventDispatcher 支持类型安全、优先级和传播控制
- 添加 PropertyBinding 响应式属性绑定系统
- 添加 ServiceContainer 依赖注入容器
- 添加 UIConfig 全局配置系统
- 添加 UIObjectFactory 对象工厂
- 实现 RenderBridge 渲染桥接层
- 实现 Canvas2DBackend 作为默认渲染后端
- 扩展 IRenderCollector 支持更多图元类型

* feat(fairygui): 九宫格渲染和资源加载修复

- 修复 FGUIUpdateSystem 支持路径和 GUID 两种加载方式
- 修复 GTextInput 同时设置 _displayObject 和 _textField
- 实现九宫格渲染展开为 9 个子图元
- 添加 sourceWidth/sourceHeight 用于九宫格计算
- 添加 DOMTextRenderer 文本渲染层(临时方案)

* fix(fairygui): 修复 GGraph 颜色读取

* feat(fairygui): 虚拟节点 Inspector 和文本渲染支持

* fix(fairygui): 编辑器状态刷新和遗留引用修复

- 修复切换 FGUI 包后组件列表未刷新问题
- 修复切换组件后 viewport 未清理旧内容问题
- 修复虚拟节点在包加载后未刷新问题
- 重构为事件驱动架构,移除轮询机制
- 修复 @esengine/ui 遗留引用,统一使用 @esengine/fairygui

* fix: 移除 tsconfig 中的 @esengine/ui 引用
This commit is contained in:
YHH
2025-12-22 10:52:54 +08:00
committed by GitHub
parent 96b5403d14
commit a1e1189f9d
237 changed files with 30983 additions and 23563 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -9,7 +9,8 @@
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { Entity, Core, HierarchySystem, HierarchyComponent, EntityTags, isFolder, PrefabSerializer, ComponentRegistry, getComponentInstanceTypeName, PrefabInstanceComponent } from '@esengine/ecs-framework';
import type { PrefabData, ComponentType } from '@esengine/ecs-framework';
import { EntityStoreService, MessageHub, CommandManager, EntityCreationRegistry, EntityCreationTemplate, PrefabService, AssetRegistryService, ProjectService } from '@esengine/editor-core';
import { EntityStoreService, MessageHub, CommandManager, EntityCreationRegistry, EntityCreationTemplate, PrefabService, AssetRegistryService, ProjectService, VirtualNodeRegistry } from '@esengine/editor-core';
import type { IVirtualNode } from '@esengine/editor-core';
import { useLocale } from '../hooks/useLocale';
import { useHierarchyStore } from '../stores';
import * as LucideIcons from 'lucide-react';
@@ -48,6 +49,36 @@ const categoryIconMap: Record<string, string> = {
'other': 'MoreHorizontal',
};
/**
* Map virtual node types to Lucide icon names
* 将虚拟节点类型映射到 Lucide 图标名称
*/
const virtualNodeIconMap: Record<string, string> = {
'Component': 'LayoutGrid',
'Image': 'Image',
'Graph': 'Square',
'TextField': 'Type',
'RichTextField': 'FileText',
'Button': 'MousePointer',
'List': 'List',
'Loader': 'Loader',
'ProgressBar': 'BarChart',
'Slider': 'Sliders',
'ComboBox': 'ChevronDown',
'ScrollPane': 'Scroll',
'Group': 'FolderOpen',
'MovieClip': 'Film',
'TextInput': 'FormInput',
};
/**
* Get icon name for a virtual node type
* 获取虚拟节点类型的图标名称
*/
function getVirtualNodeIcon(nodeType: string): string {
return virtualNodeIconMap[nodeType] || 'Circle';
}
// 实体类型到图标的映射
const entityTypeIcons: Record<string, React.ReactNode> = {
'World': <Mountain size={14} className="entity-type-icon world" />,
@@ -78,6 +109,21 @@ interface EntityNode {
depth: number;
bIsFolder: boolean;
hasChildren: boolean;
/** Virtual nodes from components (e.g., FGUI internal nodes) | 组件的虚拟节点(如 FGUI 内部节点) */
virtualNodes?: IVirtualNode[];
}
/**
* Flattened list item - can be either an entity node or a virtual node
* 扁平化列表项 - 可以是实体节点或虚拟节点
*/
interface FlattenedItem {
type: 'entity' | 'virtual';
entityNode?: EntityNode;
virtualNode?: IVirtualNode;
depth: number;
parentEntityId: number;
hasChildren: boolean;
}
/**
@@ -140,6 +186,15 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf
const [showFilterMenu, setShowFilterMenu] = useState(false);
const [editingEntityId, setEditingEntityId] = useState<number | null>(null);
const [editingName, setEditingName] = useState('');
// Expanded virtual node IDs (format: "entityId:virtualNodeId")
// 展开的虚拟节点 ID格式"entityId:virtualNodeId"
const [expandedVirtualIds, setExpandedVirtualIds] = useState<Set<string>>(new Set());
// Selected virtual node (format: "entityId:virtualNodeId")
// 选中的虚拟节点(格式:"entityId:virtualNodeId"
const [selectedVirtualId, setSelectedVirtualId] = useState<string | null>(null);
// Refresh counter to force virtual nodes recollection
// 刷新计数器,用于强制重新收集虚拟节点
const [virtualNodeRefreshKey, setVirtualNodeRefreshKey] = useState(0);
const { t, locale } = useLocale();
// Ref for auto-scrolling to selected item | 选中项自动滚动 ref
@@ -173,6 +228,10 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf
/**
* 构建层级树结构
* Build hierarchical tree structure
*
* Also collects virtual nodes from components using VirtualNodeRegistry.
* 同时使用 VirtualNodeRegistry 收集组件的虚拟节点。
*/
const buildEntityTree = useCallback((rootEntities: Entity[]): EntityNode[] => {
const scene = Core.scene;
@@ -191,12 +250,17 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf
}
}
// Collect virtual nodes from components
// 从组件收集虚拟节点
const virtualNodes = VirtualNodeRegistry.getAllVirtualNodesForEntity(entity);
return {
entity,
children,
depth,
bIsFolder: bIsEntityFolder,
hasChildren: children.length > 0
hasChildren: children.length > 0 || virtualNodes.length > 0,
virtualNodes: virtualNodes.length > 0 ? virtualNodes : undefined
};
};
@@ -205,17 +269,68 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf
/**
* 扁平化树为带深度信息的列表(用于渲染)
* Flatten tree to list with depth info (for rendering)
*
* Also includes virtual nodes when their parent entity is expanded.
* 当父实体展开时,也包含虚拟节点。
*/
const flattenTree = useCallback((nodes: EntityNode[], expandedSet: Set<number>): EntityNode[] => {
const result: EntityNode[] = [];
const flattenTree = useCallback((
nodes: EntityNode[],
expandedSet: Set<number>,
expandedVirtualSet: Set<string>
): FlattenedItem[] => {
const result: FlattenedItem[] = [];
// Flatten virtual nodes recursively
// 递归扁平化虚拟节点
const flattenVirtualNodes = (
virtualNodes: IVirtualNode[],
parentEntityId: number,
baseDepth: number
) => {
for (const vnode of virtualNodes) {
const vnodeKey = `${parentEntityId}:${vnode.id}`;
const hasVChildren = vnode.children && vnode.children.length > 0;
result.push({
type: 'virtual',
virtualNode: vnode,
depth: baseDepth,
parentEntityId,
hasChildren: hasVChildren
});
// If virtual node is expanded, add its children
// 如果虚拟节点已展开,添加其子节点
if (hasVChildren && expandedVirtualSet.has(vnodeKey)) {
flattenVirtualNodes(vnode.children, parentEntityId, baseDepth + 1);
}
}
};
const traverse = (nodeList: EntityNode[]) => {
for (const node of nodeList) {
result.push(node);
// Add entity node
result.push({
type: 'entity',
entityNode: node,
depth: node.depth,
parentEntityId: node.entity.id,
hasChildren: node.hasChildren
});
const bIsExpanded = expandedSet.has(node.entity.id);
if (bIsExpanded && node.children.length > 0) {
traverse(node.children);
if (bIsExpanded) {
// Add child entities
if (node.children.length > 0) {
traverse(node.children);
}
// Add virtual nodes after entity children
// 在实体子节点后添加虚拟节点
if (node.virtualNodes && node.virtualNodes.length > 0) {
flattenVirtualNodes(node.virtualNodes, node.entity.id, node.depth + 1);
}
}
}
};
@@ -226,13 +341,92 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf
/**
* 层级树和扁平化列表
* Hierarchy tree and flattened list
*
* virtualNodeRefreshKey is used to force rebuild when components change.
* virtualNodeRefreshKey 用于在组件变化时强制重建。
*/
const entityTree = useMemo(() => buildEntityTree(entities), [entities, buildEntityTree]);
const flattenedEntities = useMemo(
() => expandedIds.has(-1) ? flattenTree(entityTree, expandedIds) : [],
[entityTree, expandedIds, flattenTree]
// eslint-disable-next-line react-hooks/exhaustive-deps
const entityTree = useMemo(() => buildEntityTree(entities), [entities, buildEntityTree, virtualNodeRefreshKey]);
const flattenedItems = useMemo(
() => expandedIds.has(-1) ? flattenTree(entityTree, expandedIds, expandedVirtualIds) : [],
[entityTree, expandedIds, expandedVirtualIds, flattenTree]
);
/**
* Toggle virtual node expansion
* 切换虚拟节点展开状态
*/
const toggleVirtualExpand = useCallback((parentEntityId: number, virtualNodeId: string) => {
const key = `${parentEntityId}:${virtualNodeId}`;
setExpandedVirtualIds(prev => {
const next = new Set(prev);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
return next;
});
}, []);
/**
* Handle virtual node click
* 处理虚拟节点点击
*/
const handleVirtualNodeClick = useCallback((parentEntityId: number, virtualNode: IVirtualNode) => {
const key = `${parentEntityId}:${virtualNode.id}`;
setSelectedVirtualId(key);
// Clear entity selection when selecting virtual node
// 选择虚拟节点时清除实体选择
setSelectedIds(new Set());
// Publish event for Inspector to display virtual node properties
// 发布事件以便 Inspector 显示虚拟节点属性
messageHub.publish('virtual-node:selected', {
parentEntityId,
virtualNodeId: virtualNode.id,
virtualNode
});
}, [messageHub, setSelectedIds]);
// Subscribe to scene:modified to refresh virtual nodes when components change
// 订阅 scene:modified 事件,当组件变化时刷新虚拟节点
useEffect(() => {
const unsubModified = messageHub.subscribe('scene:modified', () => {
setVirtualNodeRefreshKey(prev => prev + 1);
});
// Also subscribe to component-specific events
// 同时订阅组件相关事件
const unsubComponentAdded = messageHub.subscribe('component:added', () => {
setVirtualNodeRefreshKey(prev => prev + 1);
});
const unsubComponentRemoved = messageHub.subscribe('component:removed', () => {
setVirtualNodeRefreshKey(prev => prev + 1);
});
return () => {
unsubModified();
unsubComponentAdded();
unsubComponentRemoved();
};
}, [messageHub]);
// Subscribe to VirtualNodeRegistry changes (event-driven, no polling needed)
// 订阅 VirtualNodeRegistry 变化(事件驱动,无需轮询)
useEffect(() => {
const unsubscribe = VirtualNodeRegistry.onChange((event) => {
// Refresh if the changed entity is expanded
// 如果变化的实体是展开的,则刷新
if (expandedIds.has(event.entityId)) {
setVirtualNodeRefreshKey(prev => prev + 1);
}
});
return unsubscribe;
}, [expandedIds]);
// 获取插件实体创建模板 | Get entity creation templates from plugins
useEffect(() => {
const updateTemplates = () => {
@@ -257,6 +451,14 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf
// Note: Scene/entity/remote subscriptions moved to useStoreSubscriptions
const handleEntityClick = (entity: Entity, e: React.MouseEvent) => {
// Clear virtual node selection when selecting an entity
// 选择实体时清除虚拟节点选择
setSelectedVirtualId(null);
// Force refresh virtual nodes to pick up any newly loaded components
// 强制刷新虚拟节点以获取新加载的组件
setVirtualNodeRefreshKey(prev => prev + 1);
if (e.ctrlKey || e.metaKey) {
setSelectedIds(prev => {
const next = new Set(prev);
@@ -927,22 +1129,26 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf
}
// 方向键导航 | Arrow key navigation
if ((e.key === 'ArrowUp' || e.key === 'ArrowDown') && flattenedEntities.length > 0) {
// Only navigate entity nodes, skip virtual nodes
// 只导航实体节点,跳过虚拟节点
const entityItems = flattenedItems.filter(item => item.type === 'entity');
if ((e.key === 'ArrowUp' || e.key === 'ArrowDown') && entityItems.length > 0) {
e.preventDefault();
const currentIndex = selectedId
? flattenedEntities.findIndex(n => n.entity.id === selectedId)
? entityItems.findIndex(item => item.entityNode?.entity.id === selectedId)
: -1;
let newIndex: number;
if (e.key === 'ArrowUp') {
newIndex = currentIndex <= 0 ? flattenedEntities.length - 1 : currentIndex - 1;
newIndex = currentIndex <= 0 ? entityItems.length - 1 : currentIndex - 1;
} else {
newIndex = currentIndex >= flattenedEntities.length - 1 ? 0 : currentIndex + 1;
newIndex = currentIndex >= entityItems.length - 1 ? 0 : currentIndex + 1;
}
const newEntity = flattenedEntities[newIndex]?.entity;
const newEntity = entityItems[newIndex]?.entityNode?.entity;
if (newEntity) {
setSelectedIds(new Set([newEntity.id]));
setSelectedVirtualId(null); // Clear virtual selection
entityStore.selectEntity(newEntity);
messageHub.publish('entity:selected', { entity: newEntity });
}
@@ -952,7 +1158,7 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [selectedId, isShowingRemote, editingEntityId, flattenedEntities, entityStore, messageHub, handleStartRename, handleConfirmRename, handleCancelRename, handleDuplicateEntity]);
}, [selectedId, isShowingRemote, editingEntityId, flattenedItems, entityStore, messageHub, handleStartRename, handleConfirmRename, handleCancelRename, handleDuplicateEntity]);
/**
* 创建文件夹实体
@@ -1303,107 +1509,164 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf
</div>
</div>
{/* Hierarchical Entity Items */}
{flattenedEntities.map((node) => {
const { entity, depth, hasChildren, bIsFolder } = node;
const bIsExpanded = expandedIds.has(entity.id);
const bIsSelected = selectedIds.has(entity.id);
const bIsDragging = draggedEntityId === entity.id;
const currentDropTarget = dropTarget?.entityId === entity.id ? dropTarget : null;
const bIsPrefabInstance = isEntityPrefabInstance(entity);
{/* Hierarchical Entity and Virtual Node Items */}
{flattenedItems.map((item, index) => {
// Render entity node
if (item.type === 'entity' && item.entityNode) {
const node = item.entityNode;
const { entity, bIsFolder } = node;
const bIsExpanded = expandedIds.has(entity.id);
const bIsSelected = selectedIds.has(entity.id);
const bIsDragging = draggedEntityId === entity.id;
const currentDropTarget = dropTarget?.entityId === entity.id ? dropTarget : null;
const bIsPrefabInstance = isEntityPrefabInstance(entity);
// 计算缩进 (每层 16px加上基础 8px)
const indent = 8 + depth * 16;
// 计算缩进 (每层 16px加上基础 8px)
const indent = 8 + item.depth * 16;
// 构建 drop indicator 类名
let dropIndicatorClass = '';
if (currentDropTarget) {
dropIndicatorClass = `drop-${currentDropTarget.indicator}`;
// 构建 drop indicator 类名
let dropIndicatorClass = '';
if (currentDropTarget) {
dropIndicatorClass = `drop-${currentDropTarget.indicator}`;
}
return (
<div
key={`entity-${entity.id}`}
ref={bIsSelected ? selectedItemRef : undefined}
className={`outliner-item ${bIsSelected ? 'selected' : ''} ${bIsDragging ? 'dragging' : ''} ${dropIndicatorClass} ${bIsPrefabInstance ? 'prefab-instance' : ''}`}
style={{ paddingLeft: `${indent}px` }}
draggable
onClick={(e) => handleEntityClick(entity, e)}
onDragStart={(e) => handleDragStart(e, entity.id)}
onDragOver={(e) => handleDragOver(e, node)}
onDragLeave={handleDragLeave}
onDrop={(e) => handleDrop(e, node)}
onDragEnd={handleDragEnd}
onContextMenu={(e) => {
e.stopPropagation();
handleEntityClick(entity, e);
handleContextMenu(e, entity.id);
}}
>
<div className="outliner-item-icons">
{isEntityVisible(entity) ? (
<Eye
size={12}
className="item-icon visibility"
onClick={(e) => handleToggleVisibility(entity, e)}
/>
) : (
<EyeOff
size={12}
className="item-icon visibility hidden"
onClick={(e) => handleToggleVisibility(entity, e)}
/>
)}
</div>
<div className="outliner-item-content">
{/* 展开/折叠按钮 */}
{item.hasChildren || bIsFolder ? (
<span
className="outliner-item-expand clickable"
onClick={(e) => { e.stopPropagation(); toggleExpand(entity.id); }}
>
{bIsExpanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
</span>
) : (
<span className="outliner-item-expand" />
)}
{getEntityIcon(entity)}
{editingEntityId === entity.id ? (
<input
className="outliner-item-name-input"
value={editingName}
onChange={(e) => setEditingName(e.target.value)}
onBlur={handleConfirmRename}
onKeyDown={(e) => {
if (e.key === 'Enter') handleConfirmRename();
if (e.key === 'Escape') handleCancelRename();
e.stopPropagation();
}}
onClick={(e) => e.stopPropagation()}
autoFocus
/>
) : (
<span
className="outliner-item-name"
onDoubleClick={(e) => {
e.stopPropagation();
setEditingEntityId(entity.id);
setEditingName(entity.name || '');
}}
>
{entity.name || `Entity ${entity.id}`}
</span>
)}
{/* 预制体实例徽章 | Prefab instance badge */}
{bIsPrefabInstance && (
<span className="prefab-badge" title={t('inspector.prefab.instance', {}, 'Prefab Instance')}>
P
</span>
)}
</div>
<div className="outliner-item-type">{getEntityType(entity)}</div>
</div>
);
}
return (
<div
key={entity.id}
ref={bIsSelected ? selectedItemRef : undefined}
className={`outliner-item ${bIsSelected ? 'selected' : ''} ${bIsDragging ? 'dragging' : ''} ${dropIndicatorClass} ${bIsPrefabInstance ? 'prefab-instance' : ''}`}
style={{ paddingLeft: `${indent}px` }}
draggable
onClick={(e) => handleEntityClick(entity, e)}
onDragStart={(e) => handleDragStart(e, entity.id)}
onDragOver={(e) => handleDragOver(e, node)}
onDragLeave={handleDragLeave}
onDrop={(e) => handleDrop(e, node)}
onDragEnd={handleDragEnd}
onContextMenu={(e) => {
e.stopPropagation();
handleEntityClick(entity, e);
handleContextMenu(e, entity.id);
}}
>
<div className="outliner-item-icons">
{isEntityVisible(entity) ? (
<Eye
size={12}
className="item-icon visibility"
onClick={(e) => handleToggleVisibility(entity, e)}
/>
) : (
<EyeOff
size={12}
className="item-icon visibility hidden"
onClick={(e) => handleToggleVisibility(entity, e)}
/>
)}
// Render virtual node (read-only)
// 渲染虚拟节点(只读)
if (item.type === 'virtual' && item.virtualNode) {
const vnode = item.virtualNode;
const vnodeKey = `${item.parentEntityId}:${vnode.id}`;
const bIsVExpanded = expandedVirtualIds.has(vnodeKey);
const bIsVSelected = selectedVirtualId === vnodeKey;
// 计算缩进
const indent = 8 + item.depth * 16;
return (
<div
key={`virtual-${vnodeKey}-${index}`}
className={`outliner-item virtual-node ${bIsVSelected ? 'selected' : ''} ${!vnode.visible ? 'hidden-node' : ''}`}
style={{ paddingLeft: `${indent}px` }}
onClick={() => handleVirtualNodeClick(item.parentEntityId, vnode)}
>
<div className="outliner-item-icons">
{vnode.visible ? (
<Eye size={12} className="item-icon visibility" />
) : (
<EyeOff size={12} className="item-icon visibility hidden" />
)}
</div>
<div className="outliner-item-content">
{/* 展开/折叠按钮 */}
{item.hasChildren ? (
<span
className="outliner-item-expand clickable"
onClick={(e) => {
e.stopPropagation();
toggleVirtualExpand(item.parentEntityId, vnode.id);
}}
>
{bIsVExpanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
</span>
) : (
<span className="outliner-item-expand" />
)}
{/* 虚拟节点类型图标 */}
{getIconComponent(getVirtualNodeIcon(vnode.type), 14)}
<span className="outliner-item-name virtual-name">
{vnode.name}
</span>
</div>
<div className="outliner-item-type virtual-type">{vnode.type}</div>
</div>
<div className="outliner-item-content">
{/* 展开/折叠按钮 */}
{hasChildren || bIsFolder ? (
<span
className="outliner-item-expand clickable"
onClick={(e) => { e.stopPropagation(); toggleExpand(entity.id); }}
>
{bIsExpanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
</span>
) : (
<span className="outliner-item-expand" />
)}
{getEntityIcon(entity)}
{editingEntityId === entity.id ? (
<input
className="outliner-item-name-input"
value={editingName}
onChange={(e) => setEditingName(e.target.value)}
onBlur={handleConfirmRename}
onKeyDown={(e) => {
if (e.key === 'Enter') handleConfirmRename();
if (e.key === 'Escape') handleCancelRename();
e.stopPropagation();
}}
onClick={(e) => e.stopPropagation()}
autoFocus
/>
) : (
<span
className="outliner-item-name"
onDoubleClick={(e) => {
e.stopPropagation();
setEditingEntityId(entity.id);
setEditingName(entity.name || '');
}}
>
{entity.name || `Entity ${entity.id}`}
</span>
)}
{/* 预制体实例徽章 | Prefab instance badge */}
{bIsPrefabInstance && (
<span className="prefab-badge" title={t('inspector.prefab.instance', {}, 'Prefab Instance')}>
P
</span>
)}
</div>
<div className="outliner-item-type">{getEntityType(entity)}</div>
</div>
);
);
}
return null;
})}
</div>
)

View File

@@ -184,14 +184,23 @@ export function SettingsWindow({ onClose, settingsRegistry, initialCategoryId }:
}
}
console.log('[SettingsWindow] Initial values for profiler:',
Array.from(initialValues.entries()).filter(([k]) => k.startsWith('profiler.')));
setValues(initialValues);
}, [settingsRegistry, initialCategoryId]);
const handleValueChange = (key: string, value: any, descriptor: SettingDescriptor) => {
const newValues = new Map(values);
newValues.set(key, value);
// When preset is selected, also update width and height values
// 当选择预设时,同时更新宽度和高度值
if (key === 'project.uiDesignResolution.preset' && typeof value === 'string' && value.includes('x')) {
const [w, h] = value.split('x').map(Number);
if (w && h) {
newValues.set('project.uiDesignResolution.width', w);
newValues.set('project.uiDesignResolution.height', h);
}
}
setValues(newValues);
const newErrors = new Map(errors);
@@ -218,7 +227,6 @@ export function SettingsWindow({ onClose, settingsRegistry, initialCategoryId }:
if (!shouldDeferSave) {
settings.set(key, value);
console.log(`[SettingsWindow] Saved ${key}:`, value);
// 触发设置变更事件
// Trigger settings changed event
@@ -237,28 +245,27 @@ export function SettingsWindow({ onClose, settingsRegistry, initialCategoryId }:
const projectService = Core.services.tryResolve<ProjectService>(ProjectService);
const changedSettings: Record<string, any> = {};
let uiResolutionChanged = false;
let newWidth = 1920;
let newHeight = 1080;
// Get width and height directly from values - these are the actual UI input values
// 直接从 values 获取宽高 - 这些是实际的 UI 输入值
const widthFromValues = values.get('project.uiDesignResolution.width');
const heightFromValues = values.get('project.uiDesignResolution.height');
// Use the width/height values directly (they are always set from either user input or initial load)
// 直接使用 width/height 值(它们总是从用户输入或初始加载设置的)
const newWidth = typeof widthFromValues === 'number' ? widthFromValues : 1920;
const newHeight = typeof heightFromValues === 'number' ? heightFromValues : 1080;
// Check if resolution differs from saved config
// 检查分辨率是否与保存的配置不同
const currentResolution = projectService?.getUIDesignResolution() || { width: 1920, height: 1080 };
const uiResolutionChanged = newWidth !== currentResolution.width || newHeight !== currentResolution.height;
let disabledModulesChanged = false;
let newDisabledModules: string[] = [];
for (const [key, value] of values.entries()) {
if (key.startsWith('project.') && projectService) {
if (key === 'project.uiDesignResolution.width') {
newWidth = value;
uiResolutionChanged = true;
} else if (key === 'project.uiDesignResolution.height') {
newHeight = value;
uiResolutionChanged = true;
} else if (key === 'project.uiDesignResolution.preset') {
const [w, h] = value.split('x').map(Number);
if (w && h) {
newWidth = w;
newHeight = h;
uiResolutionChanged = true;
}
} else if (key === 'project.disabledModules') {
if (key === 'project.disabledModules') {
newDisabledModules = value as string[];
disabledModulesChanged = true;
}
@@ -270,7 +277,9 @@ export function SettingsWindow({ onClose, settingsRegistry, initialCategoryId }:
}
if (uiResolutionChanged && projectService) {
console.log(`[SettingsWindow] Saving UI resolution: ${newWidth}x${newHeight}`);
await projectService.setUIDesignResolution({ width: newWidth, height: newHeight });
console.log(`[SettingsWindow] UI resolution saved, verifying: ${JSON.stringify(projectService.getUIDesignResolution())}`);
}
if (disabledModulesChanged && projectService) {
@@ -570,14 +579,14 @@ export function SettingsWindow({ onClose, settingsRegistry, initialCategoryId }:
const selectedCategory = categories.find((c) => c.id === selectedCategoryId);
return (
<div className="settings-overlay" onClick={handleCancel}>
<div className="settings-overlay" onClick={handleSave}>
<div className="settings-window-new" onClick={(e) => e.stopPropagation()}>
{/* Left Sidebar */}
<div className="settings-sidebar-new">
<div className="settings-sidebar-header">
<SettingsIcon size={16} />
<span>{t('settingsWindow.editorPreferences')}</span>
<button className="settings-sidebar-close" onClick={handleCancel}>
<button className="settings-sidebar-close" onClick={handleSave}>
<X size={14} />
</button>
</div>

View File

@@ -10,12 +10,12 @@ import { useLocale } from '../hooks/useLocale';
import { EngineService } from '../services/EngineService';
import { Core, Entity, SceneSerializer, PrefabSerializer, GlobalComponentRegistry } from '@esengine/ecs-framework';
import type { PrefabData, ComponentType } from '@esengine/ecs-framework';
import { MessageHub, ProjectService, AssetRegistryService, EntityStoreService, CommandManager, SceneManagerService, UserCodeService, UserCodeTarget } from '@esengine/editor-core';
import { MessageHub, ProjectService, AssetRegistryService, EntityStoreService, CommandManager, SceneManagerService, UserCodeService, UserCodeTarget, VirtualNodeRegistry } from '@esengine/editor-core';
import { InstantiatePrefabCommand } from '../application/commands/prefab/InstantiatePrefabCommand';
import { TransformCommand, type TransformState, type TransformOperationType } from '../application/commands';
import { TransformComponent } from '@esengine/engine-core';
import { CameraComponent } from '@esengine/camera';
import { UITransformComponent } from '@esengine/ui';
import { FGUIComponent } from '@esengine/fairygui';
import { TauriAPI } from '../api/tauri';
import { open } from '@tauri-apps/plugin-shell';
import { RuntimeResolver } from '../services/RuntimeResolver';
@@ -302,7 +302,7 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport
const transformModeRef = useRef<TransformMode>('select');
// Initial transform state for undo/redo | 用于撤销/重做的初始变换状态
const initialTransformStateRef = useRef<TransformState | null>(null);
const transformComponentRef = useRef<TransformComponent | UITransformComponent | null>(null);
const transformComponentRef = useRef<TransformComponent | null>(null);
const snapEnabledRef = useRef(true);
const gridSnapRef = useRef(10);
const rotationSnapRef = useRef(15);
@@ -454,18 +454,48 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport
if (gizmoService) {
const worldPos = screenToWorld(e.clientX, e.clientY);
const zoom = camera2DZoomRef.current;
const hitEntityId = gizmoService.handleClick(worldPos.x, worldPos.y, zoom);
const clickResult = gizmoService.handleClickEx(worldPos.x, worldPos.y, zoom);
if (hitEntityId !== null) {
if (clickResult !== null) {
// Find and select the hit entity
// 找到并选中命中的实体
const scene = Core.scene;
if (scene) {
const hitEntity = scene.entities.findEntityById(hitEntityId);
const hitEntity = scene.entities.findEntityById(clickResult.entityId);
if (hitEntity && messageHubRef.current) {
const entityStore = Core.services.tryResolve(EntityStoreService);
entityStore?.selectEntity(hitEntity);
messageHubRef.current.publish('entity:selected', { entity: hitEntity });
// Check if clicked on a virtual node
// 检查是否点击了虚拟节点
if (clickResult.virtualNodeId) {
// Get the virtual node data from VirtualNodeRegistry
// 从 VirtualNodeRegistry 获取虚拟节点数据
const virtualNodes = VirtualNodeRegistry.getAllVirtualNodesForEntity(hitEntity);
const findVirtualNode = (nodes: typeof virtualNodes, targetId: string): typeof virtualNodes[0] | null => {
for (const node of nodes) {
if (node.id === targetId) return node;
const found = findVirtualNode(node.children, targetId);
if (found) return found;
}
return null;
};
const virtualNode = findVirtualNode(virtualNodes, clickResult.virtualNodeId);
if (virtualNode) {
// Publish virtual-node:selected event (will trigger Inspector update)
// 发布 virtual-node:selected 事件(将触发 Inspector 更新)
messageHubRef.current.publish('virtual-node:selected', {
parentEntityId: clickResult.entityId,
virtualNodeId: clickResult.virtualNodeId,
virtualNode
});
}
} else {
// Normal entity selection
// 普通实体选择
messageHubRef.current.publish('entity:selected', { entity: hitEntity });
}
e.preventDefault();
return; // Don't start camera pan
}
@@ -487,13 +517,9 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport
const entity = selectedEntityRef.current;
if (entity) {
const transform = entity.getComponent(TransformComponent);
const uiTransform = entity.getComponent(UITransformComponent);
if (transform) {
initialTransformStateRef.current = TransformCommand.captureTransformState(transform);
transformComponentRef.current = transform;
} else if (uiTransform) {
initialTransformStateRef.current = TransformCommand.captureUITransformState(uiTransform);
transformComponentRef.current = uiTransform;
}
}
}
@@ -573,63 +599,6 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport
}
}
// Try UITransformComponent
const uiTransform = entity.getComponent(UITransformComponent);
if (uiTransform) {
if (mode === 'move') {
uiTransform.x += worldDelta.x;
uiTransform.y += worldDelta.y;
} else if (mode === 'rotate') {
const rotationSpeed = 0.01;
uiTransform.rotation += deltaX * rotationSpeed;
} else if (mode === 'scale') {
const oldWidth = uiTransform.width * uiTransform.scaleX;
const oldHeight = uiTransform.height * uiTransform.scaleY;
// pivot点的世界坐标缩放前
const pivotWorldX = uiTransform.x + oldWidth * uiTransform.pivotX;
const pivotWorldY = uiTransform.y + oldHeight * uiTransform.pivotY;
const startDist = Math.sqrt((worldStart.x - pivotWorldX) ** 2 + (worldStart.y - pivotWorldY) ** 2);
const endDist = Math.sqrt((worldEnd.x - pivotWorldX) ** 2 + (worldEnd.y - pivotWorldY) ** 2);
if (startDist > 0) {
const scaleFactor = endDist / startDist;
const newScaleX = uiTransform.scaleX * scaleFactor;
const newScaleY = uiTransform.scaleY * scaleFactor;
const newWidth = uiTransform.width * newScaleX;
const newHeight = uiTransform.height * newScaleY;
// 调整位置使pivot点保持不动
uiTransform.x = pivotWorldX - newWidth * uiTransform.pivotX;
uiTransform.y = pivotWorldY - newHeight * uiTransform.pivotY;
uiTransform.scaleX = newScaleX;
uiTransform.scaleY = newScaleY;
}
}
// Update live transform display for UI | 更新 UI 的实时变换显示
setLiveTransform({
type: mode as 'move' | 'rotate' | 'scale',
x: uiTransform.x,
y: uiTransform.y,
rotation: uiTransform.rotation * 180 / Math.PI,
scaleX: uiTransform.scaleX,
scaleY: uiTransform.scaleY
});
if (messageHubRef.current) {
const propertyName = mode === 'move' ? 'x' : mode === 'rotate' ? 'rotation' : 'scaleX';
messageHubRef.current.publish('component:property:changed', {
entity,
component: uiTransform,
propertyName,
value: uiTransform[propertyName]
});
}
}
lastMousePosRef.current = { x: e.clientX, y: e.clientY };
} else {
// Not dragging - update gizmo hover state
@@ -683,18 +652,6 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport
}
}
const uiTransform = entity.getComponent(UITransformComponent);
if (uiTransform) {
if (mode === 'move') {
uiTransform.x = snapToGrid(uiTransform.x);
uiTransform.y = snapToGrid(uiTransform.y);
} else if (mode === 'rotate') {
uiTransform.rotation = snapRotation(uiTransform.rotation);
} else if (mode === 'scale') {
uiTransform.scaleX = snapScale(uiTransform.scaleX);
uiTransform.scaleY = snapScale(uiTransform.scaleY);
}
}
}
// Create TransformCommand for undo/redo | 创建变换命令用于撤销/重做
@@ -705,13 +662,7 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport
if (entity && initialState && component && hub && cmdManager) {
const mode = transformModeRef.current as TransformOperationType;
let newState: TransformState;
if (component instanceof TransformComponent) {
newState = TransformCommand.captureTransformState(component);
} else {
newState = TransformCommand.captureUITransformState(component as UITransformComponent);
}
const newState = TransformCommand.captureTransformState(component);
// Only create command if state actually changed | 只有状态实际改变时才创建命令
const hasChanged = JSON.stringify(initialState) !== JSON.stringify(newState);
@@ -1715,58 +1666,115 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport
const handleViewportDrop = useCallback(async (e: React.DragEvent) => {
const assetPath = e.dataTransfer.getData('asset-path');
if (!assetPath || !assetPath.toLowerCase().endsWith('.prefab')) {
const assetGuid = e.dataTransfer.getData('asset-guid');
const lowerPath = assetPath?.toLowerCase() || '';
if (!assetPath) {
return;
}
// Check for supported asset types | 检查支持的资产类型
const isPrefab = lowerPath.endsWith('.prefab');
const isFui = lowerPath.endsWith('.fui');
if (!isPrefab && !isFui) {
return;
}
e.preventDefault();
// 获取服务 | Get services
const entityStore = Core.services.tryResolve(EntityStoreService) as EntityStoreService | null;
const scene = Core.scene;
if (!entityStore || !scene || !messageHub) {
console.error('[Viewport] Required services not available');
return;
}
// 计算放置位置(将屏幕坐标转换为世界坐标)| Calculate drop position (convert screen to world)
const canvas = canvasRef.current;
let worldPos = { x: 0, y: 0 };
if (canvas) {
const rect = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
const screenX = e.clientX - rect.left;
const screenY = e.clientY - rect.top;
const canvasX = screenX * dpr;
const canvasY = screenY * dpr;
const centeredX = canvasX - canvas.width / 2;
const centeredY = canvas.height / 2 - canvasY;
worldPos = {
x: centeredX / camera2DZoomRef.current - camera2DOffsetRef.current.x,
y: centeredY / camera2DZoomRef.current - camera2DOffsetRef.current.y
};
}
try {
// 读取预制体文件 | Read prefab file
const prefabJson = await TauriAPI.readFileContent(assetPath);
const prefabData = PrefabSerializer.deserialize(prefabJson);
// 获取服务 | Get services
const entityStore = Core.services.tryResolve(EntityStoreService) as EntityStoreService | null;
if (!entityStore || !messageHub || !commandManager) {
console.error('[Viewport] Required services not available');
return;
}
// 计算放置位置(将屏幕坐标转换为世界坐标)| Calculate drop position (convert screen to world)
const canvas = canvasRef.current;
let worldPos = { x: 0, y: 0 };
if (canvas) {
const rect = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
const screenX = e.clientX - rect.left;
const screenY = e.clientY - rect.top;
const canvasX = screenX * dpr;
const canvasY = screenY * dpr;
const centeredX = canvasX - canvas.width / 2;
const centeredY = canvas.height / 2 - canvasY;
worldPos = {
x: centeredX / camera2DZoomRef.current - camera2DOffsetRef.current.x,
y: centeredY / camera2DZoomRef.current - camera2DOffsetRef.current.y
};
}
// 创建实例化命令 | Create instantiate command
const command = new InstantiatePrefabCommand(
entityStore,
messageHub,
prefabData,
{
position: worldPos,
trackInstance: true
if (isPrefab) {
// 处理预制体 | Handle prefab
if (!commandManager) {
console.error('[Viewport] CommandManager not available');
return;
}
);
commandManager.execute(command);
console.log(`[Viewport] Prefab instantiated at (${worldPos.x.toFixed(0)}, ${worldPos.y.toFixed(0)}): ${prefabData.metadata.name}`);
const prefabJson = await TauriAPI.readFileContent(assetPath);
const prefabData = PrefabSerializer.deserialize(prefabJson);
const command = new InstantiatePrefabCommand(
entityStore,
messageHub,
prefabData,
{
position: worldPos,
trackInstance: true
}
);
commandManager.execute(command);
console.log(`[Viewport] Prefab instantiated at (${worldPos.x.toFixed(0)}, ${worldPos.y.toFixed(0)}): ${prefabData.metadata.name}`);
} else if (isFui) {
// 处理 FUI 文件 | Handle FUI file
const filename = assetPath.split(/[/\\]/).pop() || 'FGUI View';
const entityName = filename.replace('.fui', '');
// 生成唯一名称 | Generate unique name
const existingCount = entityStore.getAllEntities()
.filter((ent: Entity) => ent.name.startsWith(entityName)).length;
const finalName = existingCount > 0 ? `${entityName} ${existingCount + 1}` : entityName;
// 创建实体 | Create entity
const entity = scene.createEntity(finalName);
// 添加 TransformComponent | Add TransformComponent
const transform = new TransformComponent();
transform.position.x = worldPos.x;
transform.position.y = worldPos.y;
entity.addComponent(transform);
// 添加 FGUIComponent | Add FGUIComponent
const fguiComponent = new FGUIComponent();
// 优先使用 GUID如果没有则使用路径编辑器会通过 AssetRegistry 解析)
// Prefer GUID, fallback to path (editor resolves via AssetRegistry)
fguiComponent.packageGuid = assetGuid || assetPath;
fguiComponent.width = 1920;
fguiComponent.height = 1080;
entity.addComponent(fguiComponent);
// 注册并选中实体 | Register and select entity
entityStore.addEntity(entity);
messageHub.publish('entity:added', { entity });
messageHub.publish('scene:modified', {});
entityStore.selectEntity(entity);
console.log(`[Viewport] FGUI entity created at (${worldPos.x.toFixed(0)}, ${worldPos.y.toFixed(0)}): ${finalName}`);
}
} catch (error) {
console.error('[Viewport] Failed to instantiate prefab:', error);
console.error('[Viewport] Failed to handle drop:', error);
messageHub?.publish('notification:error', {
title: 'Drop Failed',
message: error instanceof Error ? error.message : String(error)
});
}
}, [messageHub, commandManager]);

File diff suppressed because it is too large Load Diff

View File

@@ -15,7 +15,8 @@ import {
ExtensionInspector,
AssetFileInspector,
RemoteEntityInspector,
PrefabInspector
PrefabInspector,
VirtualNodeInspector
} from './views';
import { EntityInspectorPanel } from '../inspector';
@@ -112,5 +113,14 @@ export function Inspector({ entityStore: _entityStore, messageHub, inspectorRegi
);
}
if (target.type === 'virtual-node') {
return (
<VirtualNodeInspector
parentEntityId={target.data.parentEntityId}
virtualNode={target.data.virtualNode}
/>
);
}
return null;
}

View File

@@ -1,5 +1,6 @@
import { Entity } from '@esengine/ecs-framework';
import { EntityStoreService, MessageHub, InspectorRegistry, CommandManager } from '@esengine/editor-core';
import type { IVirtualNode } from '@esengine/editor-core';
export interface InspectorProps {
entityStore: EntityStoreService;
@@ -20,11 +21,22 @@ export interface AssetFileInfo {
type ExtensionData = Record<string, any>;
/**
* Virtual node target data
* 虚拟节点目标数据
*/
export interface VirtualNodeTargetData {
parentEntityId: number;
virtualNodeId: string;
virtualNode: IVirtualNode;
}
export type InspectorTarget =
| { type: 'entity'; data: Entity }
| { type: 'remote-entity'; data: RemoteEntity; details?: EntityDetails }
| { type: 'asset-file'; data: AssetFileInfo; content?: string; isImage?: boolean }
| { type: 'extension'; data: ExtensionData }
| { type: 'virtual-node'; data: VirtualNodeTargetData }
| null;
export interface RemoteEntity {

View File

@@ -0,0 +1,324 @@
/**
* 虚拟节点检查器
* Virtual Node Inspector
*
* 显示 FGUI 等组件内部虚拟节点的只读属性
* Displays read-only properties of virtual nodes from components like FGUI
*/
import type { IVirtualNode } from '@esengine/editor-core';
import { Box, Eye, EyeOff, Move, Maximize2, RotateCw, Palette, Type, Image, Square, Layers, MousePointer, Sliders } from 'lucide-react';
import '../../../styles/VirtualNodeInspector.css';
interface VirtualNodeInspectorProps {
parentEntityId: number;
virtualNode: IVirtualNode;
}
/**
* Format number to fixed decimal places
* 格式化数字到固定小数位
*/
function formatNumber(value: number | undefined, decimals: number = 2): string {
if (value === undefined || value === null) return '-';
return value.toFixed(decimals);
}
/**
* Property row component
* 属性行组件
*/
function PropertyRow({ label, value, icon }: { label: string; value: React.ReactNode; icon?: React.ReactNode }) {
return (
<div className="virtual-node-property-row">
<span className="property-label">
{icon && <span className="property-icon">{icon}</span>}
{label}
</span>
<span className="property-value">{value}</span>
</div>
);
}
/**
* Section component
* 分组组件
*/
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="virtual-node-section">
<div className="section-header">{title}</div>
<div className="section-content">{children}</div>
</div>
);
}
/**
* Color swatch component for displaying colors
* 颜色色块组件
*/
function ColorSwatch({ color }: { color: string }) {
return (
<span className="color-swatch-wrapper">
<span
className="color-swatch"
style={{ backgroundColor: color }}
/>
<span className="color-value">{color}</span>
</span>
);
}
/**
* Check if a property key is for common/transform properties
* 检查属性键是否为公共/变换属性
*/
const COMMON_PROPS = new Set([
'className', 'x', 'y', 'width', 'height', 'alpha', 'visible',
'touchable', 'rotation', 'scaleX', 'scaleY', 'pivotX', 'pivotY', 'grayed'
]);
/**
* Property categories for type-specific display
* 类型特定显示的属性分类
*/
const TYPE_SPECIFIC_SECTIONS: Record<string, { title: string; icon: React.ReactNode; props: string[] }> = {
Graph: {
title: '图形属性 | Graph',
icon: <Square size={12} />,
props: ['graphType', 'lineSize', 'lineColor', 'fillColor', 'cornerRadius', 'sides', 'startAngle']
},
Image: {
title: '图像属性 | Image',
icon: <Image size={12} />,
props: ['color', 'flip', 'fillMethod', 'fillOrigin', 'fillClockwise', 'fillAmount']
},
TextField: {
title: '文本属性 | Text',
icon: <Type size={12} />,
props: ['text', 'font', 'fontSize', 'color', 'align', 'valign', 'leading', 'letterSpacing',
'bold', 'italic', 'underline', 'singleLine', 'autoSize', 'stroke', 'strokeColor']
},
Loader: {
title: '加载器属性 | Loader',
icon: <Image size={12} />,
props: ['url', 'align', 'verticalAlign', 'fill', 'shrinkOnly', 'autoSize', 'color',
'fillMethod', 'fillOrigin', 'fillClockwise', 'fillAmount']
},
Button: {
title: '按钮属性 | Button',
icon: <MousePointer size={12} />,
props: ['title', 'icon', 'mode', 'selected', 'titleColor', 'titleFontSize',
'selectedTitle', 'selectedIcon']
},
List: {
title: '列表属性 | List',
icon: <Layers size={12} />,
props: ['defaultItem', 'itemCount', 'selectedIndex', 'scrollPane']
},
ProgressBar: {
title: '进度条属性 | Progress',
icon: <Sliders size={12} />,
props: ['value', 'max']
},
Slider: {
title: '滑块属性 | Slider',
icon: <Sliders size={12} />,
props: ['value', 'max']
},
Component: {
title: '组件属性 | Component',
icon: <Layers size={12} />,
props: ['numChildren', 'numControllers', 'numTransitions']
}
};
/**
* Format a property value for display
* 格式化属性值以供显示
*/
function formatPropertyValue(key: string, value: unknown): React.ReactNode {
if (value === null || value === undefined) {
return '-';
}
// Color properties - show color swatch
if (typeof value === 'string' && (
key.toLowerCase().includes('color') ||
key === 'fillColor' ||
key === 'lineColor' ||
key === 'strokeColor' ||
key === 'titleColor'
)) {
if (value.startsWith('#') || value.startsWith('rgb')) {
return <ColorSwatch color={value} />;
}
}
if (typeof value === 'boolean') {
return value ? 'Yes' : 'No';
}
if (typeof value === 'number') {
return formatNumber(value);
}
if (typeof value === 'string') {
// Truncate long strings
if (value.length > 50) {
return value.substring(0, 47) + '...';
}
return value || '-';
}
return JSON.stringify(value);
}
export function VirtualNodeInspector({ parentEntityId, virtualNode }: VirtualNodeInspectorProps) {
const { name, type, visible, x, y, width, height, data } = virtualNode;
// Extract additional properties from data
// 从 data 中提取额外属性
const alpha = data.alpha as number | undefined;
const rotation = data.rotation as number | undefined;
const scaleX = data.scaleX as number | undefined;
const scaleY = data.scaleY as number | undefined;
const touchable = data.touchable as boolean | undefined;
const grayed = data.grayed as boolean | undefined;
const pivotX = data.pivotX as number | undefined;
const pivotY = data.pivotY as number | undefined;
// Get type-specific section config
const typeSection = TYPE_SPECIFIC_SECTIONS[type];
// Collect type-specific properties
const typeSpecificProps: Array<{ key: string; value: unknown }> = [];
const otherProps: Array<{ key: string; value: unknown }> = [];
Object.entries(data).forEach(([key, value]) => {
if (COMMON_PROPS.has(key)) {
return; // Skip common props
}
if (typeSection?.props.includes(key)) {
typeSpecificProps.push({ key, value });
} else {
otherProps.push({ key, value });
}
});
return (
<div className="entity-inspector virtual-node-inspector">
{/* Header */}
<div className="virtual-node-header">
<Box size={16} className="header-icon" />
<div className="header-info">
<div className="header-name">{name}</div>
<div className="header-type">{type}</div>
</div>
<div className="header-badge">
Virtual Node
</div>
</div>
{/* Read-only notice */}
<div className="virtual-node-notice">
</div>
{/* Basic Properties */}
<Section title="基本属性 | Basic">
<PropertyRow
label="Visible"
value={visible ? <Eye size={14} /> : <EyeOff size={14} className="disabled" />}
/>
{touchable !== undefined && (
<PropertyRow
label="Touchable"
value={touchable ? 'Yes' : 'No'}
/>
)}
{grayed !== undefined && (
<PropertyRow
label="Grayed"
value={grayed ? 'Yes' : 'No'}
/>
)}
{alpha !== undefined && (
<PropertyRow
label="Alpha"
value={formatNumber(alpha)}
icon={<Palette size={12} />}
/>
)}
</Section>
{/* Transform */}
<Section title="变换 | Transform">
<PropertyRow
label="Position"
value={`(${formatNumber(x)}, ${formatNumber(y)})`}
icon={<Move size={12} />}
/>
<PropertyRow
label="Size"
value={`${formatNumber(width)} × ${formatNumber(height)}`}
icon={<Maximize2 size={12} />}
/>
{(rotation !== undefined && rotation !== 0) && (
<PropertyRow
label="Rotation"
value={`${formatNumber(rotation)}°`}
icon={<RotateCw size={12} />}
/>
)}
{(scaleX !== undefined || scaleY !== undefined) && (
<PropertyRow
label="Scale"
value={`(${formatNumber(scaleX ?? 1)}, ${formatNumber(scaleY ?? 1)})`}
/>
)}
{(pivotX !== undefined || pivotY !== undefined) && (
<PropertyRow
label="Pivot"
value={`(${formatNumber(pivotX ?? 0)}, ${formatNumber(pivotY ?? 0)})`}
/>
)}
</Section>
{/* Type-Specific Properties */}
{typeSection && typeSpecificProps.length > 0 && (
<Section title={typeSection.title}>
{typeSpecificProps.map(({ key, value }) => (
<PropertyRow
key={key}
label={key}
value={formatPropertyValue(key, value)}
icon={key === typeSection.props[0] ? typeSection.icon : undefined}
/>
))}
</Section>
)}
{/* Other Properties */}
{otherProps.length > 0 && (
<Section title="其他属性 | Other">
{otherProps.map(({ key, value }) => (
<PropertyRow
key={key}
label={key}
value={formatPropertyValue(key, value)}
/>
))}
</Section>
)}
{/* Debug Info */}
<Section title="调试信息 | Debug">
<PropertyRow label="Parent Entity ID" value={parentEntityId} />
<PropertyRow label="Virtual Node ID" value={virtualNode.id} />
<PropertyRow label="Child Count" value={virtualNode.children?.length ?? 0} />
</Section>
</div>
);
}

View File

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

View File

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

View File

@@ -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 设计分辨率

View File

@@ -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.
*/

View File

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

View File

@@ -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.
* 从文件加载音频。

View 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;

View File

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

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