import { useState, useEffect, useRef } from 'react'; import { Entity, Core } from '@esengine/ecs-framework'; import { EntityStoreService, MessageHub, SceneManagerService, CommandManager, EntityCreationRegistry, EntityCreationTemplate } from '@esengine/editor-core'; import { useLocale } from '../hooks/useLocale'; import * as LucideIcons from 'lucide-react'; import { Box, Wifi, Search, Plus, Trash2, Monitor, Globe, ChevronRight, ChevronDown, Eye, Star, Lock, Settings, Filter, Folder, Sun, Cloud, Mountain, Flag, SquareStack } from 'lucide-react'; import { ProfilerService, RemoteEntity } from '../services/ProfilerService'; import { confirm } from '@tauri-apps/plugin-dialog'; import { CreateEntityCommand, DeleteEntityCommand } from '../application/commands/entity'; import '../styles/SceneHierarchy.css'; function getIconComponent(iconName: string | undefined, size: number = 14): React.ReactNode { if (!iconName) return ; const icons = LucideIcons as unknown as Record>; const IconComponent = icons[iconName]; if (IconComponent) { return ; } return ; } const categoryIconMap: Record = { 'rendering': 'Image', 'ui': 'LayoutGrid', 'physics': 'Box', 'audio': 'Volume2', 'basic': 'Plus', 'other': 'MoreHorizontal', }; // 实体类型到图标的映射 const entityTypeIcons: Record = { 'World': , 'Folder': , 'DirectionalLight': , 'SkyLight': , 'SkyAtmosphere': , 'VolumetricCloud': , 'StaticMeshActor': , 'PlayerStart': , 'ExponentialHeightFog': , }; type ViewMode = 'local' | 'remote'; type SortColumn = 'name' | 'type'; type SortDirection = 'asc' | 'desc'; interface SceneHierarchyProps { entityStore: EntityStoreService; messageHub: MessageHub; commandManager: CommandManager; isProfilerMode?: boolean; } interface EntityNode { entity: Entity; children: EntityNode[]; isExpanded: boolean; depth: number; } export function SceneHierarchy({ entityStore, messageHub, commandManager, isProfilerMode = false }: SceneHierarchyProps) { const [entities, setEntities] = useState([]); const [remoteEntities, setRemoteEntities] = useState([]); const [isRemoteConnected, setIsRemoteConnected] = useState(false); const [viewMode, setViewMode] = useState(isProfilerMode ? 'remote' : 'local'); const [selectedIds, setSelectedIds] = useState>(new Set()); const [searchQuery, setSearchQuery] = useState(''); const [sceneName, setSceneName] = useState('Untitled'); const [remoteSceneName, setRemoteSceneName] = useState(null); const [sceneFilePath, setSceneFilePath] = useState(null); const [isSceneModified, setIsSceneModified] = useState(false); const [contextMenu, setContextMenu] = useState<{ x: number; y: number; entityId: number | null } | null>(null); const [draggedEntityId, setDraggedEntityId] = useState(null); const [dropTargetIndex, setDropTargetIndex] = useState(null); const [pluginTemplates, setPluginTemplates] = useState([]); const [expandedFolders, setExpandedFolders] = useState>(new Set()); const [sortColumn, setSortColumn] = useState('name'); const [sortDirection, setSortDirection] = useState('asc'); const [showFilterMenu, setShowFilterMenu] = useState(false); const { t, locale } = useLocale(); const isShowingRemote = viewMode === 'remote' && isRemoteConnected; const selectedId = selectedIds.size > 0 ? Array.from(selectedIds)[0] : null; // Get entity creation templates from plugins useEffect(() => { const updateTemplates = () => { const registry = Core.services.resolve(EntityCreationRegistry); if (registry) { setPluginTemplates(registry.getAll()); } }; updateTemplates(); const unsubInstalled = messageHub.subscribe('plugin:installed', updateTemplates); const unsubUninstalled = messageHub.subscribe('plugin:uninstalled', updateTemplates); return () => { unsubInstalled(); unsubUninstalled(); }; }, [messageHub]); // Subscribe to scene changes useEffect(() => { const sceneManager = Core.services.resolve(SceneManagerService); const updateSceneInfo = () => { if (sceneManager) { const state = sceneManager.getSceneState(); setSceneName(state.sceneName); setIsSceneModified(state.isModified); setSceneFilePath(state.currentScenePath || null); } }; updateSceneInfo(); const unsubLoaded = messageHub.subscribe('scene:loaded', (data: any) => { if (data.sceneName) { setSceneName(data.sceneName); setSceneFilePath(data.path || null); setIsSceneModified(data.isModified || false); } else { updateSceneInfo(); } }); const unsubNew = messageHub.subscribe('scene:new', () => { updateSceneInfo(); }); const unsubSaved = messageHub.subscribe('scene:saved', () => { updateSceneInfo(); }); const unsubModified = messageHub.subscribe('scene:modified', updateSceneInfo); return () => { unsubLoaded(); unsubNew(); unsubSaved(); unsubModified(); }; }, [messageHub]); // Subscribe to local entity changes useEffect(() => { const updateEntities = () => { setEntities([...entityStore.getRootEntities()]); }; const handleSelection = (data: { entity: Entity | null }) => { if (data.entity) { setSelectedIds(new Set([data.entity.id])); } else { setSelectedIds(new Set()); } }; updateEntities(); const unsubAdd = messageHub.subscribe('entity:added', updateEntities); const unsubRemove = messageHub.subscribe('entity:removed', updateEntities); const unsubClear = messageHub.subscribe('entities:cleared', updateEntities); const unsubSelect = messageHub.subscribe('entity:selected', handleSelection); const unsubSceneLoaded = messageHub.subscribe('scene:loaded', updateEntities); const unsubSceneNew = messageHub.subscribe('scene:new', updateEntities); const unsubReordered = messageHub.subscribe('entity:reordered', updateEntities); return () => { unsubAdd(); unsubRemove(); unsubClear(); unsubSelect(); unsubSceneLoaded(); unsubSceneNew(); unsubReordered(); }; }, [entityStore, messageHub]); // Subscribe to remote entity data from ProfilerService useEffect(() => { const profilerService = (window as any).__PROFILER_SERVICE__ as ProfilerService | undefined; if (!profilerService) { return; } const initiallyConnected = profilerService.isConnected(); setIsRemoteConnected(initiallyConnected); const unsubscribe = profilerService.subscribe((data) => { const connected = profilerService.isConnected(); setIsRemoteConnected(connected); if (connected && data.entities && data.entities.length > 0) { setRemoteEntities((prev) => { if (prev.length !== data.entities!.length) { return data.entities!; } const hasChanged = data.entities!.some((entity, index) => { const prevEntity = prev[index]; return !prevEntity || prevEntity.id !== entity.id || prevEntity.name !== entity.name || prevEntity.componentCount !== entity.componentCount; }); return hasChanged ? data.entities! : prev; }); if (!remoteSceneName && data.entities.length > 0 && data.entities[0]) { profilerService.requestEntityDetails(data.entities[0].id); } } else if (!connected) { setRemoteEntities([]); setRemoteSceneName(null); } }); return () => unsubscribe(); }, [remoteSceneName]); // Listen for entity details to get remote scene name useEffect(() => { const handleEntityDetails = ((event: CustomEvent) => { const details = event.detail; if (details && details.sceneName) { setRemoteSceneName(details.sceneName); } }) as EventListener; window.addEventListener('profiler:entity-details', handleEntityDetails); return () => window.removeEventListener('profiler:entity-details', handleEntityDetails); }, []); const handleEntityClick = (entity: Entity, e: React.MouseEvent) => { if (e.ctrlKey || e.metaKey) { setSelectedIds(prev => { const next = new Set(prev); if (next.has(entity.id)) { next.delete(entity.id); } else { next.add(entity.id); } return next; }); } else { setSelectedIds(new Set([entity.id])); entityStore.selectEntity(entity); } }; const handleDragStart = (e: React.DragEvent, entityId: number) => { setDraggedEntityId(entityId); e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', entityId.toString()); }; const handleDragOver = (e: React.DragEvent, index: number) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; setDropTargetIndex(index); }; const handleDragLeave = () => { setDropTargetIndex(null); }; const handleDrop = (e: React.DragEvent, targetIndex: number) => { e.preventDefault(); if (draggedEntityId !== null) { entityStore.reorderEntity(draggedEntityId, targetIndex); } setDraggedEntityId(null); setDropTargetIndex(null); }; const handleDragEnd = () => { setDraggedEntityId(null); setDropTargetIndex(null); }; const handleRemoteEntityClick = (entity: RemoteEntity) => { setSelectedIds(new Set([entity.id])); const profilerService = (window as any).__PROFILER_SERVICE__ as ProfilerService | undefined; if (profilerService) { profilerService.requestEntityDetails(entity.id); } messageHub.publish('remote-entity:selected', { entity: { id: entity.id, name: entity.name, enabled: entity.enabled, componentCount: entity.componentCount, componentTypes: entity.componentTypes } }); }; const handleCreateEntity = () => { const entityCount = entityStore.getAllEntities().length; const entityName = `Entity ${entityCount + 1}`; const command = new CreateEntityCommand( entityStore, messageHub, entityName ); commandManager.execute(command); }; const handleDeleteEntity = async () => { if (!selectedId) return; const entity = entityStore.getEntity(selectedId); if (!entity) return; const confirmed = await confirm( locale === 'zh' ? `确定要删除实体 "${entity.name}" 吗?` : `Are you sure you want to delete entity "${entity.name}"?`, { title: locale === 'zh' ? '删除实体' : 'Delete Entity', kind: 'warning' } ); if (confirmed) { const command = new DeleteEntityCommand( entityStore, messageHub, entity ); commandManager.execute(command); } }; const handleContextMenu = (e: React.MouseEvent, entityId: number | null) => { e.preventDefault(); setContextMenu({ x: e.clientX, y: e.clientY, entityId }); }; const closeContextMenu = () => { setContextMenu(null); }; useEffect(() => { const handleClick = () => closeContextMenu(); if (contextMenu) { window.addEventListener('click', handleClick); return () => window.removeEventListener('click', handleClick); } }, [contextMenu]); useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Delete' && selectedId && !isShowingRemote) { handleDeleteEntity(); } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [selectedId, isShowingRemote]); const toggleFolderExpand = (entityId: number) => { setExpandedFolders(prev => { const next = new Set(prev); if (next.has(entityId)) { next.delete(entityId); } else { next.add(entityId); } return next; }); }; const handleSortClick = (column: SortColumn) => { if (sortColumn === column) { setSortDirection(prev => prev === 'asc' ? 'desc' : 'asc'); } else { setSortColumn(column); setSortDirection('asc'); } }; // Get entity type for display const getEntityType = (entity: Entity): string => { const components = entity.components || []; if (components.length > 0) { const firstComponent = components[0]; return firstComponent?.constructor?.name || 'Entity'; } return 'Entity'; }; // Get icon for entity type const getEntityIcon = (entityType: string): React.ReactNode => { return entityTypeIcons[entityType] || ; }; // Filter entities based on search query const filterRemoteEntities = (entityList: RemoteEntity[]): RemoteEntity[] => { if (!searchQuery.trim()) return entityList; const query = searchQuery.toLowerCase(); return entityList.filter((entity) => { const name = entity.name; const id = entity.id.toString(); if (name.toLowerCase().includes(query) || id.includes(query)) { return true; } if (Array.isArray(entity.componentTypes)) { return entity.componentTypes.some((type) => type.toLowerCase().includes(query) ); } return false; }); }; const filterLocalEntities = (entityList: Entity[]): Entity[] => { if (!searchQuery.trim()) return entityList; const query = searchQuery.toLowerCase(); return entityList.filter((entity) => { const name = entity.name || ''; const id = entity.id.toString(); return name.toLowerCase().includes(query) || id.includes(query); }); }; const displayEntities = isShowingRemote ? filterRemoteEntities(remoteEntities) : filterLocalEntities(entities); const showRemoteIndicator = isShowingRemote && remoteEntities.length > 0; const displaySceneName = isShowingRemote && remoteSceneName ? remoteSceneName : sceneName; const totalCount = displayEntities.length; const selectedCount = selectedIds.size; return (
{/* Toolbar */}
setSearchQuery(e.target.value)} />
{!isShowingRemote && ( )}
{isRemoteConnected && !isProfilerMode && (
)} {showRemoteIndicator && (
)}
{/* Column Headers */}
handleSortClick('name')} > Item Label {sortColumn === 'name' && ( {sortDirection === 'asc' ? '▲' : '▼'} )}
handleSortClick('type')} > Type {sortColumn === 'type' && ( {sortDirection === 'asc' ? '▲' : '▼'} )}
{/* Entity List */}
!isShowingRemote && handleContextMenu(e, null)}> {displayEntities.length === 0 ? (
{isShowingRemote ? (locale === 'zh' ? '远程游戏中没有实体' : 'No entities in remote game') : (locale === 'zh' ? '创建实体开始使用' : 'Create an entity to get started')}
) : isShowingRemote ? (
{(displayEntities as RemoteEntity[]).map((entity) => (
handleRemoteEntityClick(entity)} >
{getEntityIcon(entity.componentTypes?.[0] || 'Entity')} {entity.name}
{entity.componentTypes?.[0] || 'Entity'}
))}
) : (
{/* World/Scene Root */}
toggleFolderExpand(-1)} >
{ e.stopPropagation(); toggleFolderExpand(-1); }} > {expandedFolders.has(-1) ? : } {displaySceneName} (Editor)
World
{/* Entity Items */} {expandedFolders.has(-1) && entities.map((entity, index) => { const entityType = getEntityType(entity); return (
handleEntityClick(entity, e)} onDragStart={(e) => handleDragStart(e, entity.id)} onDragOver={(e) => handleDragOver(e, index)} onDragLeave={handleDragLeave} onDrop={(e) => handleDrop(e, index)} onDragEnd={handleDragEnd} onContextMenu={(e) => { e.stopPropagation(); handleEntityClick(entity, e); handleContextMenu(e, entity.id); }} >
{getEntityIcon(entityType)} {entity.name || `Entity ${entity.id}`}
{entityType}
); })}
)}
{/* Status Bar */}
{totalCount} {locale === 'zh' ? '个对象' : 'actors'} {selectedCount > 0 && ( ({selectedCount} {locale === 'zh' ? '个已选中' : 'selected'}) )}
{contextMenu && !isShowingRemote && ( { handleCreateEntity(); closeContextMenu(); }} onCreateFromTemplate={async (template) => { await template.create(contextMenu.entityId ?? undefined); closeContextMenu(); }} onDelete={() => { handleDeleteEntity(); closeContextMenu(); }} onClose={closeContextMenu} /> )}
); } interface ContextMenuWithSubmenuProps { x: number; y: number; locale: string; entityId: number | null; pluginTemplates: EntityCreationTemplate[]; onCreateEmpty: () => void; onCreateFromTemplate: (template: EntityCreationTemplate) => void; onDelete: () => void; onClose: () => void; } function ContextMenuWithSubmenu({ x, y, locale, entityId, pluginTemplates, onCreateEmpty, onCreateFromTemplate, onDelete }: ContextMenuWithSubmenuProps) { const [activeSubmenu, setActiveSubmenu] = useState(null); const [submenuPosition, setSubmenuPosition] = useState<{ x: number; y: number }>({ x: 0, y: 0 }); const menuRef = useRef(null); const categoryLabels: Record = { 'basic': { zh: '基础', en: 'Basic' }, 'rendering': { zh: '2D 对象', en: '2D Objects' }, 'ui': { zh: 'UI', en: 'UI' }, 'physics': { zh: '物理', en: 'Physics' }, 'audio': { zh: '音频', en: 'Audio' }, 'other': { zh: '其他', en: 'Other' }, }; const getCategoryLabel = (category: string) => { const labels = categoryLabels[category]; return labels ? (locale === 'zh' ? labels.zh : labels.en) : category; }; const templatesByCategory = pluginTemplates.reduce((acc, template) => { const cat = template.category || 'other'; if (!acc[cat]) acc[cat] = []; acc[cat].push(template); return acc; }, {} as Record); Object.values(templatesByCategory).forEach(templates => { templates.sort((a, b) => (a.order ?? 100) - (b.order ?? 100)); }); const handleSubmenuEnter = (category: string, e: React.MouseEvent) => { const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); setSubmenuPosition({ x: rect.right - 4, y: rect.top }); setActiveSubmenu(category); }; const categoryOrder = ['rendering', 'ui', 'physics', 'audio', 'basic', 'other']; const sortedCategories = Object.entries(templatesByCategory).sort(([a], [b]) => { const orderA = categoryOrder.indexOf(a); const orderB = categoryOrder.indexOf(b); return (orderA === -1 ? 999 : orderA) - (orderB === -1 ? 999 : orderB); }); return (
{sortedCategories.length > 0 &&
} {sortedCategories.map(([category, templates]) => (
handleSubmenuEnter(category, e)} onMouseLeave={() => setActiveSubmenu(null)} > {activeSubmenu === category && (
setActiveSubmenu(category)} > {templates.map((template) => ( ))}
)}
))} {entityId && ( <>
)}
); }