refactor(editor-app): 编辑器服务和组件优化
- EngineService 改进引擎集成 - EditorEngineSync 同步优化 - AssetFileInspector 改进 - VectorFieldEditors 优化 - InstantiatePrefabCommand 改进
This commit is contained in:
@@ -267,6 +267,17 @@ export class TauriAPI {
|
||||
return await invoke<void>('copy_file', { src, dst });
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件修改时间
|
||||
* Get file modification time
|
||||
*
|
||||
* @param path 文件路径 | File path
|
||||
* @returns 文件修改时间(毫秒时间戳)| File modification time (milliseconds timestamp)
|
||||
*/
|
||||
static async getFileMtime(path: string): Promise<number> {
|
||||
return await invoke<number>('get_file_mtime', { path });
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入二进制文件
|
||||
* @param filePath 文件路径
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* Creates an entity instance from a prefab asset.
|
||||
*/
|
||||
|
||||
import { Core, Entity, HierarchySystem, PrefabSerializer, ComponentRegistry } from '@esengine/ecs-framework';
|
||||
import { Core, Entity, HierarchySystem, PrefabSerializer, GlobalComponentRegistry } from '@esengine/ecs-framework';
|
||||
import type { EntityStoreService, MessageHub } from '@esengine/editor-core';
|
||||
import type { PrefabData, ComponentType } from '@esengine/ecs-framework';
|
||||
import { BaseCommand } from '../BaseCommand';
|
||||
@@ -50,9 +50,9 @@ export class InstantiatePrefabCommand extends BaseCommand {
|
||||
}
|
||||
|
||||
// 获取组件注册表 | Get component registry
|
||||
// ComponentRegistry.getAllComponentNames() returns Map<string, Function>
|
||||
// GlobalComponentRegistry.getAllComponentNames() returns Map<string, Function>
|
||||
// We need to cast it to Map<string, ComponentType>
|
||||
const componentRegistry = ComponentRegistry.getAllComponentNames() as Map<string, ComponentType>;
|
||||
const componentRegistry = GlobalComponentRegistry.getAllComponentNames() as Map<string, ComponentType>;
|
||||
|
||||
// 实例化预制体 | Instantiate prefab
|
||||
this.createdEntity = PrefabSerializer.instantiate(
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Folder, File as FileIcon, Image as ImageIcon, Clock, HardDrive, Settings2 } from 'lucide-react';
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { Folder, File as FileIcon, Image as ImageIcon, Clock, HardDrive, Settings2, Grid3X3 } from 'lucide-react';
|
||||
import { convertFileSrc } from '@tauri-apps/api/core';
|
||||
import { Core } from '@esengine/ecs-framework';
|
||||
import { AssetRegistryService } from '@esengine/editor-core';
|
||||
import type { ISpriteSettings } from '@esengine/asset-system-editor';
|
||||
import { EngineService } from '../../../services/EngineService';
|
||||
import { AssetFileInfo } from '../types';
|
||||
import { ImagePreview, CodePreview, getLanguageFromExtension } from '../common';
|
||||
@@ -50,6 +51,165 @@ function formatDate(timestamp?: number): string {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sprite Settings Editor Component
|
||||
* 精灵设置编辑器组件
|
||||
*
|
||||
* Allows editing nine-patch slice borders for texture assets.
|
||||
* 允许编辑纹理资源的九宫格切片边框。
|
||||
*/
|
||||
interface SpriteSettingsEditorProps {
|
||||
filePath: string;
|
||||
imageSrc: string;
|
||||
initialSettings?: ISpriteSettings;
|
||||
onSettingsChange: (settings: ISpriteSettings) => void;
|
||||
}
|
||||
|
||||
function SpriteSettingsEditor({ filePath, imageSrc, initialSettings, onSettingsChange }: SpriteSettingsEditorProps) {
|
||||
const [sliceBorder, setSliceBorder] = useState<[number, number, number, number]>(
|
||||
initialSettings?.sliceBorder || [0, 0, 0, 0]
|
||||
);
|
||||
const [imageSize, setImageSize] = useState<{ width: number; height: number } | null>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
// Sync sliceBorder state when initialSettings changes (async load)
|
||||
// 当 initialSettings 变化时同步 sliceBorder 状态(异步加载)
|
||||
useEffect(() => {
|
||||
if (initialSettings?.sliceBorder) {
|
||||
setSliceBorder(initialSettings.sliceBorder);
|
||||
}
|
||||
}, [initialSettings?.sliceBorder]);
|
||||
|
||||
// Load image to get dimensions
|
||||
// 加载图像以获取尺寸
|
||||
useEffect(() => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
setImageSize({ width: img.width, height: img.height });
|
||||
};
|
||||
img.src = imageSrc;
|
||||
}, [imageSrc]);
|
||||
|
||||
// Draw slice preview
|
||||
// 绘制切片预览
|
||||
useEffect(() => {
|
||||
if (!canvasRef.current || !imageSize) return;
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
// Calculate scale to fit canvas
|
||||
// 计算缩放以适应画布
|
||||
const maxSize = 200;
|
||||
const scale = Math.min(maxSize / img.width, maxSize / img.height, 1);
|
||||
const displayWidth = img.width * scale;
|
||||
const displayHeight = img.height * scale;
|
||||
|
||||
canvas.width = displayWidth;
|
||||
canvas.height = displayHeight;
|
||||
|
||||
// Draw image
|
||||
// 绘制图像
|
||||
ctx.drawImage(img, 0, 0, displayWidth, displayHeight);
|
||||
|
||||
// Draw slice lines
|
||||
// 绘制切片线
|
||||
const [top, right, bottom, left] = sliceBorder;
|
||||
|
||||
ctx.strokeStyle = '#00ff00';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.setLineDash([4, 4]);
|
||||
|
||||
// Top line
|
||||
if (top > 0) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, top * scale);
|
||||
ctx.lineTo(displayWidth, top * scale);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Bottom line
|
||||
if (bottom > 0) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, displayHeight - bottom * scale);
|
||||
ctx.lineTo(displayWidth, displayHeight - bottom * scale);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Left line
|
||||
if (left > 0) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(left * scale, 0);
|
||||
ctx.lineTo(left * scale, displayHeight);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Right line
|
||||
if (right > 0) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(displayWidth - right * scale, 0);
|
||||
ctx.lineTo(displayWidth - right * scale, displayHeight);
|
||||
ctx.stroke();
|
||||
}
|
||||
};
|
||||
img.src = imageSrc;
|
||||
}, [imageSrc, imageSize, sliceBorder]);
|
||||
|
||||
const handleSliceChange = (index: number, value: number) => {
|
||||
const newSlice = [...sliceBorder] as [number, number, number, number];
|
||||
newSlice[index] = Math.max(0, value);
|
||||
setSliceBorder(newSlice);
|
||||
onSettingsChange({ ...initialSettings, sliceBorder: newSlice });
|
||||
};
|
||||
|
||||
const labels = ['Top', 'Right', 'Bottom', 'Left'];
|
||||
const labelsCN = ['上', '右', '下', '左'];
|
||||
|
||||
return (
|
||||
<div className="sprite-settings-editor">
|
||||
{/* Slice Preview Canvas */}
|
||||
<div style={{ marginBottom: '12px', textAlign: 'center' }}>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{
|
||||
border: '1px solid #444',
|
||||
borderRadius: '4px',
|
||||
maxWidth: '100%'
|
||||
}}
|
||||
/>
|
||||
{imageSize && (
|
||||
<div style={{ fontSize: '11px', color: '#888', marginTop: '4px' }}>
|
||||
{imageSize.width} × {imageSize.height} px
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Slice Border Inputs */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px' }}>
|
||||
{sliceBorder.map((value, index) => (
|
||||
<div key={index} className="property-field" style={{ marginBottom: '0' }}>
|
||||
<label className="property-label" style={{ minWidth: '50px' }}>
|
||||
{labelsCN[index]} ({labels[index]})
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={value}
|
||||
onChange={(e) => handleSliceChange(index, parseInt(e.target.value) || 0)}
|
||||
min={0}
|
||||
max={imageSize ? (index % 2 === 0 ? imageSize.height : imageSize.width) : 9999}
|
||||
className="property-input property-input-number"
|
||||
style={{ width: '60px' }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AssetFileInspector({ fileInfo, content, isImage }: AssetFileInspectorProps) {
|
||||
const IconComponent = fileInfo.isDirectory ? Folder : isImage ? ImageIcon : FileIcon;
|
||||
const iconColor = fileInfo.isDirectory ? '#dcb67a' : isImage ? '#a78bfa' : '#90caf9';
|
||||
@@ -60,6 +220,10 @@ export function AssetFileInspector({ fileInfo, content, isImage }: AssetFileInsp
|
||||
const [detectedType, setDetectedType] = useState<string | null>(null);
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
|
||||
// State for sprite settings (nine-patch borders)
|
||||
// 精灵设置状态(九宫格边框)
|
||||
const [spriteSettings, setSpriteSettings] = useState<ISpriteSettings | undefined>(undefined);
|
||||
|
||||
// Load meta info and available loader types
|
||||
useEffect(() => {
|
||||
if (fileInfo.isDirectory) return;
|
||||
@@ -76,6 +240,14 @@ export function AssetFileInspector({ fileInfo, content, isImage }: AssetFileInsp
|
||||
setCurrentLoaderType(meta.loaderType || null);
|
||||
setDetectedType(meta.type);
|
||||
|
||||
// Get sprite settings from meta (for texture assets)
|
||||
// 从 meta 获取精灵设置(用于纹理资源)
|
||||
if (meta.importSettings?.spriteSettings) {
|
||||
setSpriteSettings(meta.importSettings.spriteSettings as ISpriteSettings);
|
||||
} else {
|
||||
setSpriteSettings(undefined);
|
||||
}
|
||||
|
||||
// Get available loader types from assetManager
|
||||
const assetManager = EngineService.getInstance().getAssetManager();
|
||||
const loaderFactory = assetManager?.getLoaderFactory();
|
||||
@@ -117,6 +289,39 @@ export function AssetFileInspector({ fileInfo, content, isImage }: AssetFileInsp
|
||||
}
|
||||
}, [fileInfo.path, fileInfo.name, fileInfo.isDirectory, isUpdating]);
|
||||
|
||||
// Handle sprite settings change
|
||||
// 处理精灵设置更改
|
||||
const handleSpriteSettingsChange = useCallback(async (newSettings: ISpriteSettings) => {
|
||||
if (fileInfo.isDirectory || isUpdating) return;
|
||||
|
||||
setIsUpdating(true);
|
||||
try {
|
||||
const assetRegistry = Core.services.tryResolve(AssetRegistryService) as AssetRegistryService | null;
|
||||
if (!assetRegistry?.isReady) return;
|
||||
|
||||
const metaManager = assetRegistry.metaManager;
|
||||
const meta = await metaManager.getOrCreateMeta(fileInfo.path);
|
||||
|
||||
// Update meta with new sprite settings
|
||||
// 使用新的精灵设置更新 meta
|
||||
const updatedImportSettings = {
|
||||
...meta.importSettings,
|
||||
spriteSettings: newSettings
|
||||
};
|
||||
|
||||
await metaManager.updateMeta(fileInfo.path, {
|
||||
importSettings: updatedImportSettings
|
||||
});
|
||||
|
||||
setSpriteSettings(newSettings);
|
||||
console.log(`[AssetFileInspector] Updated sprite settings for ${fileInfo.name}:`, newSettings);
|
||||
} catch (error) {
|
||||
console.error('Failed to update sprite settings:', error);
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
}, [fileInfo.path, fileInfo.name, fileInfo.isDirectory, isUpdating]);
|
||||
|
||||
return (
|
||||
<div className="entity-inspector">
|
||||
<div className="inspector-header">
|
||||
@@ -228,6 +433,23 @@ export function AssetFileInspector({ fileInfo, content, isImage }: AssetFileInsp
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sprite Settings Section - only for image files */}
|
||||
{/* 精灵设置部分 - 仅用于图像文件 */}
|
||||
{isImage && (
|
||||
<div className="inspector-section">
|
||||
<div className="section-title">
|
||||
<Grid3X3 size={14} style={{ verticalAlign: 'middle', marginRight: '4px' }} />
|
||||
九宫格设置 (Nine-Patch)
|
||||
</div>
|
||||
<SpriteSettingsEditor
|
||||
filePath={fileInfo.path}
|
||||
imageSrc={convertFileSrc(fileInfo.path)}
|
||||
initialSettings={spriteSettings}
|
||||
onSettingsChange={handleSpriteSettingsChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{content && (
|
||||
<div className="inspector-section code-preview-section">
|
||||
<div className="section-title">文件预览</div>
|
||||
|
||||
@@ -141,7 +141,27 @@ export class Vector4FieldEditor implements IFieldEditor<Vector4> {
|
||||
}
|
||||
|
||||
render({ label, value, onChange, context }: FieldEditorProps<Vector4>): React.ReactElement {
|
||||
const v = value || { x: 0, y: 0, z: 0, w: 0 };
|
||||
// Support both object {x,y,z,w} and array [0,1,2,3] formats
|
||||
// 支持对象 {x,y,z,w} 和数组 [0,1,2,3] 两种格式
|
||||
let v: Vector4;
|
||||
const isArray = Array.isArray(value);
|
||||
|
||||
if (isArray) {
|
||||
const arr = value as unknown as number[];
|
||||
v = { x: arr[0] ?? 0, y: arr[1] ?? 0, z: arr[2] ?? 0, w: arr[3] ?? 0 };
|
||||
} else {
|
||||
v = value || { x: 0, y: 0, z: 0, w: 0 };
|
||||
}
|
||||
|
||||
const handleChange = (newV: Vector4) => {
|
||||
if (isArray) {
|
||||
// Return as array if input was array
|
||||
// 如果输入是数组,则返回数组
|
||||
onChange([newV.x, newV.y, newV.z, newV.w] as unknown as Vector4);
|
||||
} else {
|
||||
onChange(newV);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="property-field">
|
||||
@@ -150,28 +170,28 @@ export class Vector4FieldEditor implements IFieldEditor<Vector4> {
|
||||
<VectorInput
|
||||
label="X"
|
||||
value={v.x}
|
||||
onChange={(x) => onChange({ ...v, x })}
|
||||
onChange={(x) => handleChange({ ...v, x })}
|
||||
readonly={context.readonly}
|
||||
axis="x"
|
||||
/>
|
||||
<VectorInput
|
||||
label="Y"
|
||||
value={v.y}
|
||||
onChange={(y) => onChange({ ...v, y })}
|
||||
onChange={(y) => handleChange({ ...v, y })}
|
||||
readonly={context.readonly}
|
||||
axis="y"
|
||||
/>
|
||||
<VectorInput
|
||||
label="Z"
|
||||
value={v.z}
|
||||
onChange={(z) => onChange({ ...v, z })}
|
||||
onChange={(z) => handleChange({ ...v, z })}
|
||||
readonly={context.readonly}
|
||||
axis="z"
|
||||
/>
|
||||
<VectorInput
|
||||
label="W"
|
||||
value={v.w}
|
||||
onChange={(w) => onChange({ ...v, w })}
|
||||
onChange={(w) => handleChange({ ...v, w })}
|
||||
readonly={context.readonly}
|
||||
axis="w"
|
||||
/>
|
||||
|
||||
@@ -278,12 +278,20 @@ export class EditorEngineSync {
|
||||
* Update sprite in engine entity.
|
||||
* 更新引擎实体的精灵。
|
||||
*
|
||||
* Note: Texture loading is now handled automatically by EngineRenderSystem.
|
||||
* 注意:纹理加载现在由EngineRenderSystem自动处理。
|
||||
* Preloads textures when textureGuid changes to ensure they're available for rendering.
|
||||
* 当 textureGuid 变更时预加载纹理以确保渲染时可用。
|
||||
*/
|
||||
private updateSprite(entity: Entity, sprite: SpriteComponent, property: string, value: any): void {
|
||||
// No manual texture loading needed - EngineRenderSystem handles it
|
||||
// 不需要手动加载纹理 - EngineRenderSystem会处理
|
||||
// When textureGuid changes, trigger texture preload
|
||||
// 当 textureGuid 变更时,触发纹理预加载
|
||||
if (property === 'textureGuid' && value) {
|
||||
const bridge = this.engineService.getBridge();
|
||||
if (bridge) {
|
||||
// Preload the texture so it's ready for the next render frame
|
||||
// 预加载纹理以便下一渲染帧时可用
|
||||
bridge.getOrLoadTextureByPath(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Core, Scene, Entity, SceneSerializer, ProfilerSDK, createLogger, Plugin
|
||||
import { CameraConfig, EngineBridgeToken, RenderSystemToken, EngineIntegrationToken } from '@esengine/ecs-engine-bindgen';
|
||||
import { TransformComponent, TransformTypeToken, CanvasElementToken } from '@esengine/engine-core';
|
||||
import { SpriteComponent, SpriteAnimatorComponent, SpriteAnimatorSystemToken } from '@esengine/sprite';
|
||||
import { ParticleSystemComponent } from '@esengine/particle';
|
||||
import { invalidateUIRenderCaches, UIRenderProviderToken, UIInputSystemToken } from '@esengine/ui';
|
||||
import * as esEngine from '@esengine/engine';
|
||||
import {
|
||||
@@ -462,6 +463,43 @@ export class EngineService {
|
||||
if (this._runtime?.bridge) {
|
||||
this._engineIntegration = new EngineIntegration(this._assetManager, this._runtime.bridge);
|
||||
|
||||
// 为 EngineIntegration 设置使用 Tauri URL 转换的 PathResolver
|
||||
// Set PathResolver for EngineIntegration that uses Tauri URL conversion
|
||||
this._engineIntegration.setPathResolver({
|
||||
catalogToRuntime: (catalogPath: string): string => {
|
||||
// 空路径直接返回
|
||||
if (!catalogPath) return catalogPath;
|
||||
|
||||
// 已经是 URL 则直接返回
|
||||
if (catalogPath.startsWith('http://') ||
|
||||
catalogPath.startsWith('https://') ||
|
||||
catalogPath.startsWith('data:') ||
|
||||
catalogPath.startsWith('asset://')) {
|
||||
return catalogPath;
|
||||
}
|
||||
|
||||
// 使用 pathTransformerFn 转换路径为 Tauri URL
|
||||
// 路径应该是相对于项目目录的,如 'assets/sparkle_yellow.png'
|
||||
let fullPath = catalogPath;
|
||||
// 如果路径不以 'assets/' 开头,添加前缀
|
||||
if (!catalogPath.startsWith('assets/') && !catalogPath.startsWith('assets\\')) {
|
||||
fullPath = `assets/${catalogPath}`;
|
||||
}
|
||||
return pathTransformerFn(fullPath);
|
||||
},
|
||||
editorToCatalog: (editorPath: string, projectRoot: string): string => {
|
||||
return editorPath; // 不需要在此上下文中使用
|
||||
},
|
||||
setBaseUrl: () => {},
|
||||
getBaseUrl: () => '',
|
||||
normalize: (path: string) => path.replace(/\\/g, '/'),
|
||||
isAbsoluteUrl: (path: string) =>
|
||||
path.startsWith('http://') ||
|
||||
path.startsWith('https://') ||
|
||||
path.startsWith('data:') ||
|
||||
path.startsWith('asset://')
|
||||
});
|
||||
|
||||
this._sceneResourceManager = new SceneResourceManager();
|
||||
this._sceneResourceManager.setResourceLoader(this._engineIntegration);
|
||||
|
||||
@@ -712,10 +750,15 @@ export class EngineService {
|
||||
return convertFileSrc(absolutePath);
|
||||
}
|
||||
return relativePath;
|
||||
} else {
|
||||
// GUID not found in registry - this could be a timing issue where asset
|
||||
// was just added but not yet registered. Log for debugging.
|
||||
// GUID 在注册表中未找到 - 可能是资源刚添加但尚未注册的时序问题
|
||||
console.warn(`[AssetPathResolver] GUID not found in registry: ${guidOrPath}. Asset may not be registered yet.`);
|
||||
}
|
||||
}
|
||||
// GUID not found, return original value
|
||||
// 未找到 GUID,返回原值
|
||||
// GUID not found, return original value (will result in white block)
|
||||
// 未找到 GUID,返回原值(会显示白块)
|
||||
return guidOrPath;
|
||||
}
|
||||
|
||||
@@ -1029,6 +1072,19 @@ export class EngineService {
|
||||
// 清除 UI 渲染缓存
|
||||
invalidateUIRenderCaches();
|
||||
|
||||
// Reset particle component textureIds before loading resources
|
||||
// 在加载资源前重置粒子组件的 textureId
|
||||
// This ensures ParticleUpdateSystem will reload textures
|
||||
// 这确保 ParticleUpdateSystem 会重新加载纹理
|
||||
if (this._runtime.scene) {
|
||||
for (const entity of this._runtime.scene.entities.buffer) {
|
||||
const particleComponent = entity.getComponent(ParticleSystemComponent);
|
||||
if (particleComponent) {
|
||||
particleComponent.textureId = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 加载场景资源
|
||||
if (this._sceneResourceManager && this._runtime.scene) {
|
||||
await this._sceneResourceManager.loadSceneResources(this._runtime.scene);
|
||||
@@ -1057,6 +1113,21 @@ export class EngineService {
|
||||
return success;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load scene resources (textures, audio, etc.)
|
||||
* 加载场景资源(纹理、音频等)
|
||||
*
|
||||
* Used by runtime scene switching in play mode.
|
||||
* 用于 Play 模式下的运行时场景切换。
|
||||
*/
|
||||
async loadSceneResources(): Promise<void> {
|
||||
const scene = this._runtime?.scene;
|
||||
if (!this._sceneResourceManager || !scene) {
|
||||
return;
|
||||
}
|
||||
await this._sceneResourceManager.loadSceneResources(scene);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a snapshot exists.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user