From 3617f40309ded5502e97421890e4ca3d708a6b94 Mon Sep 17 00:00:00 2001 From: YHH <359807859@qq.com> Date: Sat, 6 Dec 2025 14:08:48 +0800 Subject: [PATCH] =?UTF-8?q?feat(asset):=20=E7=BB=9F=E4=B8=80=E8=B5=84?= =?UTF-8?q?=E4=BA=A7=E5=BC=95=E7=94=A8=E4=BD=BF=E7=94=A8=20GUID=20?= =?UTF-8?q?=E6=9B=BF=E4=BB=A3=E8=B7=AF=E5=BE=84=20(#287)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(world-streaming): 添加世界流式加载系统 实现基于区块的世界流式加载系统,支持开放世界游戏: 运行时包 (@esengine/world-streaming): - ChunkComponent: 区块实体组件,包含坐标、边界、状态 - StreamingAnchorComponent: 流式锚点组件(玩家/摄像机) - ChunkLoaderComponent: 流式加载配置组件 - ChunkStreamingSystem: 区块加载/卸载调度系统 - ChunkCullingSystem: 区块可见性剔除系统 - ChunkManager: 区块生命周期管理服务 - SpatialHashGrid: 空间哈希网格 - ChunkSerializer: 区块序列化 编辑器包 (@esengine/world-streaming-editor): - ChunkVisualizer: 区块可视化覆盖层 - ChunkLoaderInspectorProvider: 区块加载器检视器 - StreamingAnchorInspectorProvider: 流式锚点检视器 - WorldStreamingPlugin: 完整插件导出 * feat(asset): 统一资产引用使用 GUID 替代路径 将所有组件的资产引用字段从路径改为 GUID: - SpriteComponent: texture -> textureGuid, material -> materialGuid - SpriteAnimatorComponent: AnimationFrame.texture -> textureGuid - UIRenderComponent: texture -> textureGuid - UIButtonComponent: normalTexture -> normalTextureGuid 等 - AudioSourceComponent: clip -> clipGuid - ParticleSystemComponent: 已使用 textureGuid 修复 AssetRegistryService 注册问题和路径规范化, 添加渲染系统的 GUID 解析支持。 * fix(sprite-editor): 更新 material 为 materialGuid * fix(editor-app): 更新 AnimationFrame.texture 为 textureGuid --- packages/audio/src/AudioSourceComponent.ts | 8 +- .../src/core/SpriteRenderHelper.ts | 6 +- packages/ecs-engine-bindgen/src/index.ts | 2 +- .../src/systems/EngineRenderSystem.ts | 62 +++++++- .../src/components/ContentBrowser.tsx | 25 +++- .../editor-app/src/components/FileTree.tsx | 15 +- .../inspectors/fields/AssetField.tsx | 139 ++++++++++++++---- .../AnimationClipsFieldEditor.tsx | 10 +- .../src/plugins/builtin/AssetMetaPlugin.ts | 8 +- .../src/services/EditorEngineSync.ts | 16 +- .../editor-app/src/services/EngineService.ts | 63 +++++++- .../src/Services/AssetRegistryService.ts | 29 +++- .../particle/src/ParticleSystemComponent.ts | 25 +++- .../particle/src/loaders/ParticleLoader.ts | 4 +- .../rendering/ParticleRenderDataProvider.ts | 2 +- .../src/systems/Canvas2DRenderSystem.ts | 2 +- .../src/SpriteComponentInspector.tsx | 18 +-- .../sprite/src/SpriteAnimatorComponent.ts | 23 +-- packages/sprite/src/SpriteComponent.ts | 41 +++--- .../src/systems/SpriteAnimatorSystem.ts | 2 +- packages/ui/src/UIBuilder.ts | 5 +- .../ui/src/components/UIRenderComponent.ts | 19 ++- .../components/widgets/UIButtonComponent.ts | 59 ++++---- .../systems/render/UIButtonRenderSystem.ts | 6 +- .../src/systems/render/UIRectRenderSystem.ts | 6 +- 25 files changed, 443 insertions(+), 152 deletions(-) diff --git a/packages/audio/src/AudioSourceComponent.ts b/packages/audio/src/AudioSourceComponent.ts index b78656eb..88f92ff5 100644 --- a/packages/audio/src/AudioSourceComponent.ts +++ b/packages/audio/src/AudioSourceComponent.ts @@ -1,11 +1,15 @@ import { Component, ECSComponent, Serializable, Serialize, Property } from '@esengine/ecs-framework'; @ECSComponent('AudioSource') -@Serializable({ version: 1, typeId: 'AudioSource' }) +@Serializable({ version: 2, typeId: 'AudioSource' }) export class AudioSourceComponent extends Component { + /** + * 音频资产 GUID + * Audio clip asset GUID + */ @Serialize() @Property({ type: 'asset', label: 'Audio Clip', assetType: 'audio' }) - clip: string = ''; + clipGuid: string = ''; /** 范围 [0, 1] */ @Serialize() diff --git a/packages/ecs-engine-bindgen/src/core/SpriteRenderHelper.ts b/packages/ecs-engine-bindgen/src/core/SpriteRenderHelper.ts index 47e76061..c3eaffde 100644 --- a/packages/ecs-engine-bindgen/src/core/SpriteRenderHelper.ts +++ b/packages/ecs-engine-bindgen/src/core/SpriteRenderHelper.ts @@ -109,9 +109,9 @@ export class SpriteRenderHelper { // Convert hex color string to packed RGBA const color = this.hexToPackedColor(sprite.color, sprite.alpha); - // Get material ID from path (0 = default if not found or no path specified) - const materialId = sprite.material - ? getMaterialManager().getMaterialIdByPath(sprite.material) + // Get material ID from GUID (0 = default if not found or no GUID specified) + const materialId = sprite.materialGuid + ? getMaterialManager().getMaterialIdByPath(sprite.materialGuid) : 0; // Collect material overrides if any diff --git a/packages/ecs-engine-bindgen/src/index.ts b/packages/ecs-engine-bindgen/src/index.ts index 51abf0d1..7e138efe 100644 --- a/packages/ecs-engine-bindgen/src/index.ts +++ b/packages/ecs-engine-bindgen/src/index.ts @@ -10,6 +10,6 @@ export type { EngineBridgeConfig } from './core/EngineBridge'; export { RenderBatcher } from './core/RenderBatcher'; export { SpriteRenderHelper } from './core/SpriteRenderHelper'; export type { ITransformComponent } from './core/SpriteRenderHelper'; -export { EngineRenderSystem, type TransformComponentType, type IRenderDataProvider, type IUIRenderDataProvider, type GizmoDataProviderFn, type HasGizmoProviderFn, type ProviderRenderData } from './systems/EngineRenderSystem'; +export { EngineRenderSystem, type TransformComponentType, type IRenderDataProvider, type IUIRenderDataProvider, type GizmoDataProviderFn, type HasGizmoProviderFn, type ProviderRenderData, type AssetPathResolverFn } from './systems/EngineRenderSystem'; export { CameraSystem } from './systems/CameraSystem'; export * from './types'; diff --git a/packages/ecs-engine-bindgen/src/systems/EngineRenderSystem.ts b/packages/ecs-engine-bindgen/src/systems/EngineRenderSystem.ts index 441eb024..61febc0b 100644 --- a/packages/ecs-engine-bindgen/src/systems/EngineRenderSystem.ts +++ b/packages/ecs-engine-bindgen/src/systems/EngineRenderSystem.ts @@ -119,6 +119,18 @@ export type HasGizmoProviderFn = (component: Component) => boolean; */ export type TransformComponentType = ComponentType & (new (...args: any[]) => Component & ITransformComponent); +/** + * Asset path resolver function type. + * 资产路径解析器函数类型。 + * + * Resolves GUID or path to actual file path for loading. + * 将 GUID 或路径解析为实际文件路径以进行加载。 + * + * @param guidOrPath - Asset GUID or path | 资产 GUID 或路径 + * @returns Resolved file path, or original value if cannot resolve | 解析后的文件路径,或无法解析时返回原值 + */ +export type AssetPathResolverFn = (guidOrPath: string) => string; + /** * ECS System for rendering sprites using the Rust engine. * 使用Rust引擎渲染精灵的ECS系统。 @@ -177,6 +189,10 @@ export class EngineRenderSystem extends EntitySystem { // UI 渲染数据提供者(支持屏幕空间和世界空间) private uiRenderDataProvider: IUIRenderDataProvider | null = null; + // Asset path resolver (injected from editor layer for GUID resolution) + // 资产路径解析器(从编辑器层注入,用于 GUID 解析) + private assetPathResolver: AssetPathResolverFn | null = null; + // Preview mode flag: when true, UI uses screen space overlay projection // when false (editor mode), UI renders in world space following editor camera // 预览模式标志:为 true 时,UI 使用屏幕空间叠加投影 @@ -288,14 +304,24 @@ export class EngineRenderSystem extends EntitySystem { // Use Rust engine's path-based texture loading for automatic caching // 使用Rust引擎的基于路径的纹理加载实现自动缓存 let textureId = 0; - if (sprite.texture) { - textureId = this.bridge.getOrLoadTextureByPath(sprite.texture); + const textureSource = sprite.getTextureSource(); + if (textureSource) { + // Resolve GUID to path if resolver is available + // 如果有解析器,将 GUID 解析为路径 + const texturePath = this.assetPathResolver + ? this.assetPathResolver(textureSource) + : textureSource; + textureId = this.bridge.getOrLoadTextureByPath(texturePath); } - // Get material ID from path (0 = default if not found or no path specified) - // 从路径获取材质 ID(0 = 默认,如果未找到或未指定路径) - const materialId = sprite.material - ? getMaterialManager().getMaterialIdByPath(sprite.material) + // Get material ID from GUID (0 = default if not found or no GUID specified) + // 从 GUID 获取材质 ID(0 = 默认,如果未找到或未指定 GUID) + const materialGuidOrPath = sprite.materialGuid; + const materialPath = materialGuidOrPath && this.assetPathResolver + ? this.assetPathResolver(materialGuidOrPath) + : materialGuidOrPath; + const materialId = materialPath + ? getMaterialManager().getMaterialIdByPath(materialPath) : 0; // Collect material overrides if any @@ -1159,4 +1185,28 @@ export class EngineRenderSystem extends EntitySystem { loadTexture(id: number, url: string): void { this.bridge.loadTexture(id, url); } + + /** + * Set asset path resolver. + * 设置资产路径解析器。 + * + * The resolver function is used to convert asset GUIDs to file paths. + * This allows the editor to inject AssetRegistryService functionality + * without creating a direct dependency. + * 解析器函数用于将资产 GUID 转换为文件路径。 + * 这允许编辑器注入 AssetRegistryService 功能而不创建直接依赖。 + * + * @param resolver - Function to resolve GUID/path to actual path | 将 GUID/路径解析为实际路径的函数 + */ + setAssetPathResolver(resolver: AssetPathResolverFn | null): void { + this.assetPathResolver = resolver; + } + + /** + * Get asset path resolver. + * 获取资产路径解析器。 + */ + getAssetPathResolver(): AssetPathResolverFn | null { + return this.assetPathResolver; + } } diff --git a/packages/editor-app/src/components/ContentBrowser.tsx b/packages/editor-app/src/components/ContentBrowser.tsx index 5883ac41..47ab61c4 100644 --- a/packages/editor-app/src/components/ContentBrowser.tsx +++ b/packages/editor-app/src/components/ContentBrowser.tsx @@ -38,7 +38,7 @@ import { Settings } from 'lucide-react'; import { Core } from '@esengine/ecs-framework'; -import { MessageHub, FileActionRegistry, type FileCreationTemplate } from '@esengine/editor-core'; +import { MessageHub, FileActionRegistry, AssetRegistryService, type FileCreationTemplate } from '@esengine/editor-core'; import { TauriAPI, DirectoryEntry } from '../api/tauri'; import { SettingsService } from '../services/SettingsService'; import { ContextMenu, ContextMenuItem } from './ContextMenu'; @@ -770,8 +770,19 @@ export class ${className} { const parentPath = asset.path.substring(0, lastSlash); const newPath = `${parentPath}/${newName}`; + // Update AssetMetaManager to preserve GUID | 更新 AssetMetaManager 以保持 GUID 不变 + const assetRegistry = Core.services.tryResolve(AssetRegistryService) as AssetRegistryService | null; + if (assetRegistry && asset.type !== 'folder') { + await assetRegistry.metaManager.handleAssetRename(asset.path, newPath); + } + await TauriAPI.renameFileOrFolder(asset.path, newPath); + // Refresh asset registry | 刷新资产注册表 + if (assetRegistry && asset.type !== 'folder') { + await assetRegistry.refreshAsset(newPath); + } + if (currentPath) { await loadAssets(currentPath); } @@ -1371,6 +1382,18 @@ export class ${className} { if (asset.type === 'file') { e.dataTransfer.setData('asset-path', asset.path); e.dataTransfer.setData('text/plain', asset.path); + // Add GUID for new asset reference system + const assetRegistry = Core.services.tryResolve(AssetRegistryService) as AssetRegistryService | null; + if (assetRegistry) { + // Convert absolute path to relative path for GUID lookup + const relativePath = assetRegistry.absoluteToRelative(asset.path); + if (relativePath) { + const guid = assetRegistry.getGuidByPath(relativePath); + if (guid) { + e.dataTransfer.setData('asset-guid', guid); + } + } + } } }} > diff --git a/packages/editor-app/src/components/FileTree.tsx b/packages/editor-app/src/components/FileTree.tsx index 7a6b7b17..9ab286d9 100644 --- a/packages/editor-app/src/components/FileTree.tsx +++ b/packages/editor-app/src/components/FileTree.tsx @@ -5,7 +5,7 @@ import { Save, Tag, Link, FileSearch, Globe, Package, Clipboard, RefreshCw, Settings } from 'lucide-react'; import { TauriAPI, DirectoryEntry } from '../api/tauri'; -import { MessageHub, FileActionRegistry } from '@esengine/editor-core'; +import { MessageHub, FileActionRegistry, AssetRegistryService } from '@esengine/editor-core'; import { SettingsService } from '../services/SettingsService'; import { Core } from '@esengine/ecs-framework'; import { ContextMenu, ContextMenuItem } from './ContextMenu'; @@ -999,6 +999,19 @@ export const FileTree = forwardRef(({ rootPath, o e.dataTransfer.setData('asset-extension', ext || ''); e.dataTransfer.setData('text/plain', node.path); + // Add GUID for new asset reference system + const assetRegistry = Core.services.tryResolve(AssetRegistryService) as AssetRegistryService | null; + if (assetRegistry) { + // Convert absolute path to relative path for GUID lookup + const relativePath = assetRegistry.absoluteToRelative(node.path); + if (relativePath) { + const guid = assetRegistry.getGuidByPath(relativePath); + if (guid) { + e.dataTransfer.setData('asset-guid', guid); + } + } + } + // 添加视觉反馈 e.currentTarget.style.opacity = '0.5'; } diff --git a/packages/editor-app/src/components/inspectors/fields/AssetField.tsx b/packages/editor-app/src/components/inspectors/fields/AssetField.tsx index 2cc8713b..2006e863 100644 --- a/packages/editor-app/src/components/inspectors/fields/AssetField.tsx +++ b/packages/editor-app/src/components/inspectors/fields/AssetField.tsx @@ -1,13 +1,14 @@ -import React, { useState, useRef, useCallback, useEffect } from 'react'; +import React, { useState, useRef, useCallback, useEffect, useMemo } from 'react'; import { Image, X, Navigation, ChevronDown, Copy } from 'lucide-react'; import { convertFileSrc } from '@tauri-apps/api/core'; import { Core } from '@esengine/ecs-framework'; -import { ProjectService } from '@esengine/editor-core'; +import { ProjectService, AssetRegistryService } from '@esengine/editor-core'; import { AssetPickerDialog } from '../../../components/dialogs/AssetPickerDialog'; import './AssetField.css'; interface AssetFieldProps { label?: string; + /** Value can be GUID or path (for backward compatibility) */ value: string | null; onChange: (value: string | null) => void; fileExtension?: string; @@ -17,6 +18,14 @@ interface AssetFieldProps { onCreate?: () => void; } +/** + * Check if a string is a valid UUID v4 (GUID format) + */ +function isGUID(str: string): boolean { + const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + return uuidRegex.test(str); +} + export function AssetField({ label, value, @@ -32,6 +41,24 @@ export function AssetField({ const [thumbnailUrl, setThumbnailUrl] = useState(null); const inputRef = useRef(null); + // Get AssetRegistryService for GUID ↔ Path conversion + const assetRegistry = useMemo(() => { + return Core.services.tryResolve(AssetRegistryService) as AssetRegistryService | null; + }, []); + + // Resolve value to path (value can be GUID or path) + const resolvedPath = useMemo(() => { + if (!value) return null; + + // If value is a GUID, resolve to path + if (isGUID(value) && assetRegistry) { + return assetRegistry.getPathByGuid(value) || null; + } + + // Otherwise treat as path (backward compatibility) + return value; + }, [value, assetRegistry]); + // 检测是否是图片资源 const isImageAsset = useCallback((path: string | null) => { if (!path) return false; @@ -40,18 +67,18 @@ export function AssetField({ ); }, []); - // 加载缩略图 + // 加载缩略图(使用 resolvedPath) useEffect(() => { - if (value && isImageAsset(value)) { + if (resolvedPath && isImageAsset(resolvedPath)) { // 获取项目路径并构建完整路径 const projectService = Core.services.tryResolve(ProjectService); const projectPath = projectService?.getCurrentProject()?.path; if (projectPath) { // 构建完整的文件路径 - const fullPath = value.startsWith('/') || value.includes(':') - ? value - : `${projectPath}/${value}`; + const fullPath = resolvedPath.startsWith('/') || resolvedPath.includes(':') + ? resolvedPath + : `${projectPath}/${resolvedPath}`; try { const url = convertFileSrc(fullPath); @@ -60,9 +87,9 @@ export function AssetField({ setThumbnailUrl(null); } } else { - // 没有项目路径时,尝试直接使用 value + // 没有项目路径时,尝试直接使用 resolvedPath try { - const url = convertFileSrc(value); + const url = convertFileSrc(resolvedPath); setThumbnailUrl(url); } catch { setThumbnailUrl(null); @@ -71,7 +98,7 @@ export function AssetField({ } else { setThumbnailUrl(null); } - }, [value, isImageAsset]); + }, [resolvedPath, isImageAsset]); const handleDragEnter = useCallback((e: React.DragEvent) => { e.preventDefault(); @@ -99,27 +126,66 @@ export function AssetField({ if (readonly) return; + // Try to get GUID from drag data first + const assetGuid = e.dataTransfer.getData('asset-guid'); + if (assetGuid && isGUID(assetGuid)) { + // Validate extension if needed + if (fileExtension && assetRegistry) { + const path = assetRegistry.getPathByGuid(assetGuid); + if (path && !path.endsWith(fileExtension)) { + return; // Extension mismatch + } + } + onChange(assetGuid); + return; + } + + // Fallback: handle asset-path and convert to GUID + const assetPath = e.dataTransfer.getData('asset-path'); + if (assetPath && (!fileExtension || assetPath.endsWith(fileExtension))) { + // Try to get GUID from path + if (assetRegistry) { + // Path might be absolute, convert to relative first + let relativePath = assetPath; + if (assetPath.includes(':') || assetPath.startsWith('/')) { + relativePath = assetRegistry.absoluteToRelative(assetPath) || assetPath; + } + const guid = assetRegistry.getGuidByPath(relativePath); + if (guid) { + onChange(guid); + return; + } + } + // Fallback to path if GUID not found (backward compatibility) + onChange(assetPath); + return; + } + + // Handle file drops const files = Array.from(e.dataTransfer.files); const file = files.find((f) => !fileExtension || f.name.endsWith(fileExtension) ); if (file) { + // For file drops, we still use filename (need to register first) onChange(file.name); return; } - const assetPath = e.dataTransfer.getData('asset-path'); - if (assetPath && (!fileExtension || assetPath.endsWith(fileExtension))) { - onChange(assetPath); - return; - } - const text = e.dataTransfer.getData('text/plain'); if (text && (!fileExtension || text.endsWith(fileExtension))) { + // Try to convert to GUID if it's a path + if (assetRegistry && !isGUID(text)) { + const guid = assetRegistry.getGuidByPath(text); + if (guid) { + onChange(guid); + return; + } + } onChange(text); } - }, [onChange, fileExtension, readonly]); + }, [onChange, fileExtension, readonly, assetRegistry]); const handleBrowse = useCallback(() => { if (readonly) return; @@ -127,9 +193,24 @@ export function AssetField({ }, [readonly]); const handlePickerSelect = useCallback((path: string) => { + // Convert path to GUID if possible + if (assetRegistry) { + // Path might be absolute, convert to relative first + let relativePath = path; + if (path.includes(':') || path.startsWith('/')) { + relativePath = assetRegistry.absoluteToRelative(path) || path; + } + const guid = assetRegistry.getGuidByPath(relativePath); + if (guid) { + onChange(guid); + setShowPicker(false); + return; + } + } + // Fallback to path if GUID not found onChange(path); setShowPicker(false); - }, [onChange]); + }, [onChange, assetRegistry]); const handleClear = useCallback(() => { if (!readonly) { @@ -137,11 +218,15 @@ export function AssetField({ } }, [onChange, readonly]); - const getFileName = (path: string) => { + const getFileName = (path: string | null) => { + if (!path) return placeholder; const parts = path.split(/[\\/]/); return parts[parts.length - 1]; }; + // Display name uses resolvedPath + const displayName = resolvedPath ? getFileName(resolvedPath) : placeholder; + return (
{label && } @@ -166,16 +251,16 @@ export function AssetField({ {/* 下拉选择框 */}
- {value ? getFileName(value) : placeholder} + {displayName}
@@ -183,12 +268,12 @@ export function AssetField({ {/* 操作按钮行 */}
{/* 定位按钮 */} - {value && onNavigate && ( + {resolvedPath && onNavigate && ( )} - {/* 复制路径按钮 */} - {value && ( + {/* 复制路径按钮 - copy path, not GUID */} + {resolvedPath && (