Feature/render pipeline (#232)
* refactor(engine): 重构2D渲染管线坐标系统 * feat(engine): 完善2D渲染管线和编辑器视口功能 * feat(editor): 实现Viewport变换工具系统 * feat(editor): 优化Inspector渲染性能并修复Gizmo变换工具显示 * feat(editor): 实现Run on Device移动预览功能 * feat(editor): 添加组件属性控制和依赖关系系统 * feat(editor): 实现动画预览功能和优化SpriteAnimator编辑器 * feat(editor): 修复SpriteAnimator动画预览功能并迁移CI到pnpm * feat(editor): 修复SpriteAnimator动画预览并迁移到pnpm * feat(editor): 修复SpriteAnimator动画预览并迁移到pnpm * feat(editor): 修复SpriteAnimator动画预览并迁移到pnpm * feat(editor): 修复SpriteAnimator动画预览并迁移到pnpm * feat(ci): 迁移项目到pnpm并修复CI构建问题 * chore: 迁移CI工作流到pnpm并添加WASM构建支持 * chore: 迁移CI工作流到pnpm并添加WASM构建支持 * chore: 迁移CI工作流到pnpm并添加WASM构建支持 * chore: 迁移CI工作流到pnpm并添加WASM构建支持 * chore: 迁移CI工作流到pnpm并添加WASM构建支持 * chore: 迁移CI工作流到pnpm并添加WASM构建支持 * chore: 移除 network 相关包 * chore: 移除 network 相关包
This commit is contained in:
@@ -130,6 +130,7 @@ export function Inspector({ entityStore: _entityStore, messageHub, inspectorRegi
|
||||
const unsubAssetFileSelect = messageHub.subscribe('asset-file:selected', handleAssetFileSelection);
|
||||
const unsubComponentAdded = messageHub.subscribe('component:added', handleComponentChange);
|
||||
const unsubComponentRemoved = messageHub.subscribe('component:removed', handleComponentChange);
|
||||
const unsubPropertyChanged = messageHub.subscribe('component:property:changed', handleComponentChange);
|
||||
|
||||
window.addEventListener('profiler:entity-details', handleEntityDetails);
|
||||
|
||||
@@ -140,6 +141,7 @@ export function Inspector({ entityStore: _entityStore, messageHub, inspectorRegi
|
||||
unsubAssetFileSelect();
|
||||
unsubComponentAdded();
|
||||
unsubComponentRemoved();
|
||||
unsubPropertyChanged();
|
||||
window.removeEventListener('profiler:entity-details', handleEntityDetails);
|
||||
};
|
||||
}, [messageHub]);
|
||||
|
||||
@@ -38,7 +38,7 @@ export function ComponentItem({ component, decimalPlaces = 4 }: ComponentItemPro
|
||||
}}
|
||||
>
|
||||
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
<Settings size={14} style={{ marginLeft: "4px", color: "#888" }} />
|
||||
<Settings size={14} style={{ marginLeft: '4px', color: '#888' }} />
|
||||
<span
|
||||
style={{
|
||||
marginLeft: '6px',
|
||||
|
||||
@@ -51,4 +51,4 @@ export function PropertyField({
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import React, { useState, useRef, useCallback } from 'react';
|
||||
import { FileText, Search, X, FolderOpen, ArrowRight, Package } from 'lucide-react';
|
||||
import { open } from '@tauri-apps/plugin-dialog';
|
||||
import { AssetPickerDialog } from '../../../components/dialogs/AssetPickerDialog';
|
||||
import './AssetField.css';
|
||||
|
||||
interface AssetFieldProps {
|
||||
label: string;
|
||||
label?: string;
|
||||
value: string | null;
|
||||
onChange: (value: string | null) => void;
|
||||
fileExtension?: string; // 例如: '.btree'
|
||||
@@ -24,6 +24,7 @@ export function AssetField({
|
||||
}: AssetFieldProps) {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [showPicker, setShowPicker] = useState(false);
|
||||
const inputRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleDragEnter = useCallback((e: React.DragEvent) => {
|
||||
@@ -54,7 +55,7 @@ export function AssetField({
|
||||
|
||||
// 处理从文件系统拖入的文件
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
const file = files.find(f =>
|
||||
const file = files.find((f) =>
|
||||
!fileExtension || f.name.endsWith(fileExtension)
|
||||
);
|
||||
|
||||
@@ -78,25 +79,15 @@ export function AssetField({
|
||||
}
|
||||
}, [onChange, fileExtension, readonly]);
|
||||
|
||||
const handleBrowse = useCallback(async () => {
|
||||
const handleBrowse = useCallback(() => {
|
||||
if (readonly) return;
|
||||
setShowPicker(true);
|
||||
}, [readonly]);
|
||||
|
||||
try {
|
||||
const selected = await open({
|
||||
multiple: false,
|
||||
filters: fileExtension ? [{
|
||||
name: `${fileExtension} Files`,
|
||||
extensions: [fileExtension.replace('.', '')]
|
||||
}] : []
|
||||
});
|
||||
|
||||
if (selected) {
|
||||
onChange(selected as string);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to open file dialog:', error);
|
||||
}
|
||||
}, [onChange, fileExtension, readonly]);
|
||||
const handlePickerSelect = useCallback((path: string) => {
|
||||
onChange(path);
|
||||
setShowPicker(false);
|
||||
}, [onChange]);
|
||||
|
||||
const handleClear = useCallback(() => {
|
||||
if (!readonly) {
|
||||
@@ -111,7 +102,7 @@ export function AssetField({
|
||||
|
||||
return (
|
||||
<div className="asset-field">
|
||||
<label className="asset-field__label">{label}</label>
|
||||
{label && <label className="asset-field__label">{label}</label>}
|
||||
<div
|
||||
className={`asset-field__container ${isDragging ? 'dragging' : ''} ${isHovered ? 'hovered' : ''}`}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
@@ -160,17 +151,21 @@ export function AssetField({
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 导航按钮 */}
|
||||
{value && onNavigate && (
|
||||
{/* 导航/定位按钮 */}
|
||||
{onNavigate && (
|
||||
<button
|
||||
className="asset-field__button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onNavigate(value);
|
||||
if (value) {
|
||||
onNavigate(value);
|
||||
} else {
|
||||
handleBrowse();
|
||||
}
|
||||
}}
|
||||
title="在资产浏览器中显示"
|
||||
title={value ? '在资产浏览器中显示' : '选择资产'}
|
||||
>
|
||||
<ArrowRight size={12} />
|
||||
{value ? <ArrowRight size={12} /> : <FolderOpen size={12} />}
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -189,6 +184,14 @@ export function AssetField({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AssetPickerDialog
|
||||
isOpen={showPicker}
|
||||
onClose={() => setShowPicker(false)}
|
||||
onSelect={handlePickerSelect}
|
||||
title="Select Asset"
|
||||
fileExtensions={fileExtension ? [fileExtension] : []}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,8 +54,8 @@ export function AssetFileInspector({ fileInfo, content, isImage }: AssetFileInsp
|
||||
{fileInfo.isDirectory
|
||||
? '文件夹'
|
||||
: fileInfo.extension
|
||||
? `.${fileInfo.extension}`
|
||||
: '文件'}
|
||||
? `.${fileInfo.extension}`
|
||||
: '文件'}
|
||||
</span>
|
||||
</div>
|
||||
{fileInfo.size !== undefined && !fileInfo.isDirectory && (
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useState } from 'react';
|
||||
import { Settings, ChevronDown, ChevronRight, X, Plus } from 'lucide-react';
|
||||
import { Entity, Component, Core } from '@esengine/ecs-framework';
|
||||
import { Settings, ChevronDown, ChevronRight, X, Plus, Box } from 'lucide-react';
|
||||
import { Entity, Component, Core, getComponentDependencies, getComponentTypeName } from '@esengine/ecs-framework';
|
||||
import { MessageHub, CommandManager, ComponentRegistry } from '@esengine/editor-core';
|
||||
import { PropertyInspector } from '../../PropertyInspector';
|
||||
import { NotificationService } from '../../../services/NotificationService';
|
||||
import { RemoveComponentCommand, UpdateComponentCommand, AddComponentCommand } from '../../../application/commands/component';
|
||||
import '../../../styles/EntityInspector.css';
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
|
||||
interface EntityInspectorProps {
|
||||
entity: Entity;
|
||||
@@ -16,6 +18,7 @@ interface EntityInspectorProps {
|
||||
export function EntityInspector({ entity, messageHub, commandManager, componentVersion }: EntityInspectorProps) {
|
||||
const [expandedComponents, setExpandedComponents] = useState<Set<number>>(new Set());
|
||||
const [showComponentMenu, setShowComponentMenu] = useState(false);
|
||||
const [localVersion, setLocalVersion] = useState(0);
|
||||
|
||||
const componentRegistry = Core.services.resolve(ComponentRegistry);
|
||||
const availableComponents = componentRegistry?.getAllComponents() || [];
|
||||
@@ -40,14 +43,42 @@ export function EntityInspector({ entity, messageHub, commandManager, componentV
|
||||
|
||||
const handleRemoveComponent = (index: number) => {
|
||||
const component = entity.components[index];
|
||||
if (component) {
|
||||
const command = new RemoveComponentCommand(
|
||||
messageHub,
|
||||
entity,
|
||||
component
|
||||
);
|
||||
commandManager.execute(command);
|
||||
if (!component) return;
|
||||
|
||||
const componentName = getComponentTypeName(component.constructor as any);
|
||||
console.log('Removing component:', componentName);
|
||||
|
||||
// Check if any other component depends on this one
|
||||
const dependentComponents: string[] = [];
|
||||
for (const otherComponent of entity.components) {
|
||||
if (otherComponent === component) continue;
|
||||
|
||||
const dependencies = getComponentDependencies(otherComponent.constructor as any);
|
||||
const otherName = getComponentTypeName(otherComponent.constructor as any);
|
||||
console.log('Checking', otherName, 'dependencies:', dependencies);
|
||||
if (dependencies && dependencies.includes(componentName)) {
|
||||
dependentComponents.push(otherName);
|
||||
}
|
||||
}
|
||||
console.log('Dependent components:', dependentComponents);
|
||||
|
||||
if (dependentComponents.length > 0) {
|
||||
const notificationService = Core.services.tryResolve(NotificationService) as NotificationService | null;
|
||||
if (notificationService) {
|
||||
notificationService.warning(
|
||||
'无法删除组件',
|
||||
`${componentName} 被以下组件依赖: ${dependentComponents.join(', ')}。请先删除这些组件。`
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const command = new RemoveComponentCommand(
|
||||
messageHub,
|
||||
entity,
|
||||
component
|
||||
);
|
||||
commandManager.execute(command);
|
||||
};
|
||||
|
||||
const handlePropertyChange = (component: Component, propertyName: string, value: unknown) => {
|
||||
@@ -61,6 +92,34 @@ export function EntityInspector({ entity, messageHub, commandManager, componentV
|
||||
commandManager.execute(command);
|
||||
};
|
||||
|
||||
const handlePropertyAction = async (actionId: string, _propertyName: string, component: Component) => {
|
||||
if (actionId === 'nativeSize' && component.constructor.name === 'SpriteComponent') {
|
||||
const sprite = component as unknown as { texture: string; width: number; height: number };
|
||||
if (!sprite.texture) {
|
||||
console.warn('No texture set for sprite');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { convertFileSrc } = await import('@tauri-apps/api/core');
|
||||
const assetUrl = convertFileSrc(sprite.texture);
|
||||
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
handlePropertyChange(component, 'width', img.naturalWidth);
|
||||
handlePropertyChange(component, 'height', img.naturalHeight);
|
||||
setLocalVersion((v) => v + 1);
|
||||
};
|
||||
img.onerror = () => {
|
||||
console.error('Failed to load texture for native size:', sprite.texture);
|
||||
};
|
||||
img.src = assetUrl;
|
||||
} catch (error) {
|
||||
console.error('Error getting texture size:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="entity-inspector">
|
||||
<div className="inspector-header">
|
||||
@@ -82,149 +141,123 @@ export function EntityInspector({ entity, messageHub, commandManager, componentV
|
||||
</div>
|
||||
|
||||
<div className="inspector-section">
|
||||
<div className="section-title" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div className="section-title section-title-with-action">
|
||||
<span>组件</span>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<div className="component-menu-container">
|
||||
<button
|
||||
className="add-component-trigger"
|
||||
onClick={() => setShowComponentMenu(!showComponentMenu)}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: '1px solid #4a4a4a',
|
||||
borderRadius: '4px',
|
||||
color: '#e0e0e0',
|
||||
cursor: 'pointer',
|
||||
padding: '2px 6px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
fontSize: '11px'
|
||||
}}
|
||||
>
|
||||
<Plus size={12} />
|
||||
添加
|
||||
</button>
|
||||
{showComponentMenu && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '100%',
|
||||
right: 0,
|
||||
marginTop: '4px',
|
||||
backgroundColor: '#2a2a2a',
|
||||
border: '1px solid #4a4a4a',
|
||||
borderRadius: '4px',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.3)',
|
||||
zIndex: 1000,
|
||||
minWidth: '150px',
|
||||
maxHeight: '200px',
|
||||
overflowY: 'auto'
|
||||
}}
|
||||
>
|
||||
{availableComponents.length === 0 ? (
|
||||
<div style={{ padding: '8px 12px', color: '#888', fontSize: '11px' }}>
|
||||
没有可用组件
|
||||
</div>
|
||||
) : (
|
||||
availableComponents.map((info) => (
|
||||
<button
|
||||
key={info.name}
|
||||
onClick={() => info.type && handleAddComponent(info.type)}
|
||||
style={{
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
padding: '6px 12px',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: '#e0e0e0',
|
||||
fontSize: '12px',
|
||||
textAlign: 'left',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.backgroundColor = '#3a3a3a')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = 'transparent')}
|
||||
>
|
||||
{info.name}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<>
|
||||
<div className="component-dropdown-overlay" onClick={() => setShowComponentMenu(false)} />
|
||||
<div className="component-dropdown">
|
||||
<div className="component-dropdown-header">选择组件</div>
|
||||
{availableComponents.length === 0 ? (
|
||||
<div className="component-dropdown-empty">
|
||||
没有可用组件
|
||||
</div>
|
||||
) : (
|
||||
<div className="component-dropdown-list">
|
||||
{/* 按分类分组显示 */}
|
||||
{(() => {
|
||||
const categories = new Map<string, typeof availableComponents>();
|
||||
availableComponents.forEach((info) => {
|
||||
const cat = info.category || 'components.category.other';
|
||||
if (!categories.has(cat)) {
|
||||
categories.set(cat, []);
|
||||
}
|
||||
categories.get(cat)!.push(info);
|
||||
});
|
||||
|
||||
return Array.from(categories.entries()).map(([category, components]) => (
|
||||
<div key={category} className="component-category-group">
|
||||
<div className="component-category-label">{category}</div>
|
||||
{components.map((info) => (
|
||||
<button
|
||||
key={info.name}
|
||||
className="component-dropdown-item"
|
||||
onClick={() => info.type && handleAddComponent(info.type)}
|
||||
>
|
||||
<span className="component-dropdown-item-name">{info.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
));
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{entity.components.map((component: Component, index: number) => {
|
||||
{entity.components.length === 0 ? (
|
||||
<div className="empty-state-small">暂无组件</div>
|
||||
) : (
|
||||
entity.components.map((component: Component, index: number) => {
|
||||
const isExpanded = expandedComponents.has(index);
|
||||
const componentName = component.constructor?.name || 'Component';
|
||||
const componentInfo = componentRegistry?.getComponent(componentName);
|
||||
const iconName = (componentInfo as { icon?: string } | undefined)?.icon;
|
||||
const IconComponent = iconName && (LucideIcons as unknown as Record<string, React.ComponentType<{ size?: number }>>)[iconName];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${componentName}-${index}-${componentVersion}`}
|
||||
style={{
|
||||
marginBottom: '2px',
|
||||
backgroundColor: '#2a2a2a',
|
||||
borderRadius: '4px',
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
key={`${componentName}-${index}`}
|
||||
className={`component-item-card ${isExpanded ? 'expanded' : ''}`}
|
||||
>
|
||||
<div
|
||||
className="component-item-header"
|
||||
onClick={() => toggleComponentExpanded(index)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '6px 8px',
|
||||
backgroundColor: '#3a3a3a',
|
||||
cursor: 'pointer',
|
||||
userSelect: 'none',
|
||||
borderBottom: isExpanded ? '1px solid #4a4a4a' : 'none'
|
||||
}}
|
||||
>
|
||||
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
<span
|
||||
style={{
|
||||
marginLeft: '6px',
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
color: '#e0e0e0',
|
||||
flex: 1
|
||||
}}
|
||||
>
|
||||
<span className="component-expand-icon">
|
||||
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
</span>
|
||||
{IconComponent ? (
|
||||
<span className="component-icon">
|
||||
<IconComponent size={14} />
|
||||
</span>
|
||||
) : (
|
||||
<span className="component-icon">
|
||||
<Box size={14} />
|
||||
</span>
|
||||
)}
|
||||
<span className="component-item-name">
|
||||
{componentName}
|
||||
</span>
|
||||
<button
|
||||
className="component-remove-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRemoveComponent(index);
|
||||
}}
|
||||
title="移除组件"
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: '#888',
|
||||
cursor: 'pointer',
|
||||
padding: '2px',
|
||||
borderRadius: '3px',
|
||||
display: 'flex',
|
||||
alignItems: 'center'
|
||||
}}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.color = '#dc2626')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.color = '#888')}
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div style={{ padding: '6px 8px' }}>
|
||||
<div className="component-item-content">
|
||||
<PropertyInspector
|
||||
component={component}
|
||||
entity={entity}
|
||||
version={componentVersion + localVersion}
|
||||
onChange={(propName: string, value: unknown) =>
|
||||
handlePropertyChange(component, propName, value)
|
||||
}
|
||||
onAction={handlePropertyAction}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -242,33 +242,33 @@ export function RemoteEntityInspector({
|
||||
details.components &&
|
||||
Array.isArray(details.components) &&
|
||||
details.components.length > 0 && (
|
||||
<div className="inspector-section">
|
||||
<div className="section-title">组件 ({details.components.length})</div>
|
||||
{details.components.map((comp, index) => {
|
||||
const registry = Core.services.resolve(PropertyRendererRegistry);
|
||||
const context: PropertyContext = {
|
||||
name: comp.typeName || `Component ${index}`,
|
||||
decimalPlaces,
|
||||
readonly: true,
|
||||
expandByDefault: true,
|
||||
depth: 0
|
||||
};
|
||||
const rendered = registry.render(comp, context);
|
||||
return rendered ? <div key={index}>{rendered}</div> : null;
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<div className="inspector-section">
|
||||
<div className="section-title">组件 ({details.components.length})</div>
|
||||
{details.components.map((comp, index) => {
|
||||
const registry = Core.services.resolve(PropertyRendererRegistry);
|
||||
const context: PropertyContext = {
|
||||
name: comp.typeName || `Component ${index}`,
|
||||
decimalPlaces,
|
||||
readonly: true,
|
||||
expandByDefault: true,
|
||||
depth: 0
|
||||
};
|
||||
const rendered = registry.render(comp, context);
|
||||
return rendered ? <div key={index}>{rendered}</div> : null;
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{details &&
|
||||
Object.entries(details).filter(([key]) => key !== 'components' && key !== 'componentTypes')
|
||||
.length > 0 && (
|
||||
<div className="inspector-section">
|
||||
<div className="section-title">其他信息</div>
|
||||
{Object.entries(details)
|
||||
.filter(([key]) => key !== 'components' && key !== 'componentTypes')
|
||||
.map(([key, value]) => renderRemoteProperty(key, value))}
|
||||
</div>
|
||||
)}
|
||||
<div className="inspector-section">
|
||||
<div className="section-title">其他信息</div>
|
||||
{Object.entries(details)
|
||||
.filter(([key]) => key !== 'components' && key !== 'componentTypes')
|
||||
.map(([key, value]) => renderRemoteProperty(key, value))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user