feat(asset): 统一资产引用使用 GUID 替代路径 (#287)

* 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
This commit is contained in:
YHH
2025-12-06 14:08:48 +08:00
committed by GitHub
parent 0c03b13d74
commit 3617f40309
25 changed files with 443 additions and 152 deletions

View File

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

View File

@@ -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<FileTreeHandle, FileTreeProps>(({ 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';
}

View File

@@ -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<string | null>(null);
const inputRef = useRef<HTMLDivElement>(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 (
<div className="asset-field">
{label && <label className="asset-field__label">{label}</label>}
@@ -166,16 +251,16 @@ export function AssetField({
{/* 下拉选择框 */}
<div
ref={inputRef}
className={`asset-field__dropdown ${value ? 'has-value' : ''} ${isDragging ? 'dragging' : ''}`}
className={`asset-field__dropdown ${resolvedPath ? 'has-value' : ''} ${isDragging ? 'dragging' : ''}`}
onClick={!readonly ? handleBrowse : undefined}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
title={value || placeholder}
title={resolvedPath || placeholder}
>
<span className="asset-field__value">
{value ? getFileName(value) : placeholder}
{displayName}
</span>
<ChevronDown size={12} className="asset-field__dropdown-arrow" />
</div>
@@ -183,12 +268,12 @@ export function AssetField({
{/* 操作按钮行 */}
<div className="asset-field__actions">
{/* 定位按钮 */}
{value && onNavigate && (
{resolvedPath && onNavigate && (
<button
className="asset-field__btn"
onClick={(e) => {
e.stopPropagation();
onNavigate(value);
onNavigate(resolvedPath);
}}
title="Locate in Asset Browser"
>
@@ -196,13 +281,13 @@ export function AssetField({
</button>
)}
{/* 复制路径按钮 */}
{value && (
{/* 复制路径按钮 - copy path, not GUID */}
{resolvedPath && (
<button
className="asset-field__btn"
onClick={(e) => {
e.stopPropagation();
navigator.clipboard.writeText(value);
navigator.clipboard.writeText(resolvedPath);
}}
title="Copy Path"
>

View File

@@ -169,7 +169,7 @@ function AnimationClipsEditor({ label, clips, onChange, readonly, component, onD
const newClips = [...clips];
const clip = newClips[clipIndex];
if (!clip) return;
clip.frames = [...clip.frames, { texture: '', duration: 0.1 }];
clip.frames = [...clip.frames, { textureGuid: '', duration: 0.1 }];
onChange(newClips);
};
@@ -196,8 +196,8 @@ function AnimationClipsEditor({ label, clips, onChange, readonly, component, onD
const newClips = [...clips];
const clip = newClips[clipIndex];
if (!clip) return;
const newFrames = texturePaths.map((texture) => ({
texture,
const newFrames = texturePaths.map((textureGuid) => ({
textureGuid,
duration: 0.1
}));
clip.frames = [...clip.frames, ...newFrames];
@@ -451,8 +451,8 @@ function AnimationClipsEditor({ label, clips, onChange, readonly, component, onD
<span className="frame-index">{frameIndex + 1}</span>
<div className="frame-texture-field">
<AssetField
value={frame.texture}
onChange={(val) => updateFrame(clipIndex, frameIndex, { texture: val || '' })}
value={frame.textureGuid}
onChange={(val) => updateFrame(clipIndex, frameIndex, { textureGuid: val || '' })}
fileExtension=".png"
placeholder="Texture..."
readonly={readonly}

View File

@@ -20,16 +20,20 @@ const logger = createLogger('AssetMetaPlugin');
class AssetMetaEditorModule implements IEditorModuleLoader {
private _assetRegistry: AssetRegistryService | null = null;
async install(_services: ServiceContainer): Promise<void> {
async install(services: ServiceContainer): Promise<void> {
// 创建 AssetRegistryService 并初始化
// Create AssetRegistryService and initialize
this._assetRegistry = new AssetRegistryService();
// 注册到服务容器,以便其他地方可以访问
// Register to service container so other places can access it
services.registerInstance(AssetRegistryService, this._assetRegistry);
// 初始化服务(订阅 project:opened 事件)
// Initialize service (subscribes to project:opened event)
await this._assetRegistry.initialize();
logger.info('AssetRegistryService initialized');
logger.info('AssetRegistryService initialized and registered');
}
async uninstall(): Promise<void> {

View File

@@ -155,9 +155,9 @@ export class EditorEngineSync {
if (bridge) {
for (const clip of animator.clips) {
for (const frame of clip.frames) {
if (frame.texture) {
if (frame.textureGuid) {
// Trigger texture loading
bridge.getOrLoadTextureByPath(frame.texture);
bridge.getOrLoadTextureByPath(frame.textureGuid);
}
}
}
@@ -168,8 +168,8 @@ export class EditorEngineSync {
const firstClip = animator.clips[0];
if (firstClip && firstClip.frames && firstClip.frames.length > 0) {
const firstFrame = firstClip.frames[0];
if (firstFrame && firstFrame.texture && spriteComponent) {
spriteComponent.texture = firstFrame.texture;
if (firstFrame && firstFrame.textureGuid && spriteComponent) {
spriteComponent.textureGuid = firstFrame.textureGuid;
}
}
}
@@ -228,8 +228,8 @@ export class EditorEngineSync {
// Preload all frame textures
for (const clip of animator.clips) {
for (const frame of clip.frames) {
if (frame.texture) {
bridge.getOrLoadTextureByPath(frame.texture);
if (frame.textureGuid) {
bridge.getOrLoadTextureByPath(frame.textureGuid);
}
}
}
@@ -240,8 +240,8 @@ export class EditorEngineSync {
const firstClip = animator.clips[0];
if (firstClip && firstClip.frames && firstClip.frames.length > 0) {
const firstFrame = firstClip.frames[0];
if (firstFrame && firstFrame.texture) {
sprite.texture = firstFrame.texture;
if (firstFrame && firstFrame.textureGuid) {
sprite.textureGuid = firstFrame.textureGuid;
}
}
}

View File

@@ -6,7 +6,7 @@
* Uses the unified GameRuntime architecture
*/
import { GizmoRegistry, EntityStoreService, MessageHub, SceneManagerService, ProjectService, PluginManager, IPluginManager, type SystemContext } from '@esengine/editor-core';
import { GizmoRegistry, EntityStoreService, MessageHub, SceneManagerService, ProjectService, PluginManager, IPluginManager, AssetRegistryService, type SystemContext } from '@esengine/editor-core';
import { Core, Scene, Entity, SceneSerializer, ProfilerSDK } from '@esengine/ecs-framework';
import { CameraConfig } from '@esengine/ecs-engine-bindgen';
import { TransformComponent } from '@esengine/engine-core';
@@ -148,6 +148,10 @@ export class EngineService {
// 初始化资产系统
await this._initializeAssetSystem();
// 设置资产路径解析器(用于 GUID 到路径的转换)
// Set asset path resolver (for GUID to path conversion)
this._setupAssetPathResolver();
// 同步视口尺寸
const canvas = document.getElementById(canvasId) as HTMLCanvasElement;
if (canvas && canvas.parentElement) {
@@ -339,8 +343,8 @@ export class EngineService {
const firstClip = animator.clips[0];
if (firstClip && firstClip.frames && firstClip.frames.length > 0) {
const firstFrame = firstClip.frames[0];
if (firstFrame && firstFrame.texture) {
sprite.texture = firstFrame.texture;
if (firstFrame && firstFrame.textureGuid) {
sprite.textureGuid = firstFrame.textureGuid;
}
}
}
@@ -427,6 +431,59 @@ export class EngineService {
}
}
/**
* Setup asset path resolver for EngineRenderSystem.
* 为 EngineRenderSystem 设置资产路径解析器。
*
* This enables GUID-based asset references. When a component stores a GUID,
* the resolver converts it to an actual file path for loading.
* 这启用了基于 GUID 的资产引用。当组件存储 GUID 时,
* 解析器将其转换为实际文件路径以进行加载。
*/
private _setupAssetPathResolver(): void {
const renderSystem = this._runtime?.renderSystem;
if (!renderSystem) return;
// UUID v4 regex for GUID detection
// UUID v4 正则表达式用于 GUID 检测
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;
renderSystem.setAssetPathResolver((guidOrPath: string): string => {
// Skip if already a valid URL
// 如果已经是有效的 URL 则跳过
if (!guidOrPath || guidOrPath.startsWith('http') || guidOrPath.startsWith('asset://') || guidOrPath.startsWith('data:')) {
return guidOrPath;
}
// Check if this is a GUID
// 检查是否为 GUID
if (uuidRegex.test(guidOrPath)) {
const assetRegistry = Core.services.tryResolve(AssetRegistryService) as AssetRegistryService | null;
if (assetRegistry) {
const relativePath = assetRegistry.getPathByGuid(guidOrPath);
if (relativePath) {
// Convert relative path to absolute
// 将相对路径转换为绝对路径
const absolutePath = assetRegistry.relativeToAbsolute(relativePath);
if (absolutePath) {
// Convert to Tauri asset URL for WebView loading
// 转换为 Tauri 资产 URL 以便 WebView 加载
return convertFileSrc(absolutePath);
}
return relativePath;
}
}
// GUID not found, return original value
// 未找到 GUID返回原值
return guidOrPath;
}
// Not a GUID, treat as file path and convert
// 不是 GUID当作文件路径处理并转换
return convertFileSrc(guidOrPath);
});
}
/**
* Create entity with sprite and transform.
*/