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

@@ -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()

View File

@@ -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

View File

@@ -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';

View File

@@ -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)
// 从路径获取材质 ID0 = 默认,如果未找到或未指定路径
const materialId = sprite.material
? getMaterialManager().getMaterialIdByPath(sprite.material)
// Get material ID from GUID (0 = default if not found or no GUID specified)
// 从 GUID 获取材质 ID0 = 默认,如果未找到或未指定 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;
}
}

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.
*/

View File

@@ -139,9 +139,13 @@ class SimpleAssetDatabase {
private readonly _typeToGuids = new Map<AssetRegistryType, Set<AssetGUID>>();
addAsset(metadata: IAssetRegistryMetadata): void {
const { guid, path, type } = metadata;
this._metadata.set(guid, metadata);
this._pathToGuid.set(path, guid);
const { guid, type } = metadata;
// Normalize path separators for consistent storage
const normalizedPath = metadata.path.replace(/\\/g, '/');
const normalizedMetadata = { ...metadata, path: normalizedPath };
this._metadata.set(guid, normalizedMetadata);
this._pathToGuid.set(normalizedPath, guid);
if (!this._typeToGuids.has(type)) {
this._typeToGuids.set(type, new Set());
@@ -154,6 +158,7 @@ class SimpleAssetDatabase {
if (!metadata) return;
this._metadata.delete(guid);
// Path is already normalized when stored
this._pathToGuid.delete(metadata.path);
const typeSet = this._typeToGuids.get(metadata.type);
@@ -167,7 +172,9 @@ class SimpleAssetDatabase {
}
getMetadataByPath(path: string): IAssetRegistryMetadata | undefined {
const guid = this._pathToGuid.get(path);
// Normalize path separators for consistent lookup
const normalizedPath = path.replace(/\\/g, '/');
const guid = this._pathToGuid.get(normalizedPath);
return guid ? this._metadata.get(guid) : undefined;
}
@@ -638,6 +645,11 @@ export class AssetRegistryService {
*/
getGuidByPath(relativePath: string): AssetGUID | undefined {
const metadata = this._database.getMetadataByPath(relativePath);
if (!metadata) {
// Debug: show registered paths if not found
const stats = this._database.getStatistics();
logger.debug(`[AssetRegistry] GUID not found for path: "${relativePath}", total assets: ${stats.totalAssets}`);
}
return metadata?.guid;
}
@@ -776,4 +788,13 @@ export class AssetRegistryService {
get projectPath(): string | null {
return this._projectPath;
}
/**
* Dispose the service
*/
dispose(): void {
this._unsubscribeFromFileChanges();
this.unloadProject();
this._initialized = false;
}
}

View File

@@ -54,9 +54,25 @@ export enum SimulationSpace {
* 管理粒子发射、模拟,并为渲染提供数据。
*/
@ECSComponent('ParticleSystem')
@Serializable({ version: 1, typeId: 'ParticleSystem' })
@Serializable({ version: 2, typeId: 'ParticleSystem' })
export class ParticleSystemComponent extends Component {
// ============= 资产引用 | Asset Reference =============
/**
* 粒子效果资产 GUID
* Particle effect asset GUID
*
* When set, loads particle configuration from .particle file.
* Inline properties below are ignored when asset is set.
* 设置后从 .particle 文件加载粒子配置。
* 设置了资产后,下面的内联属性将被忽略。
*/
@Serialize()
@Property({ type: 'asset', label: 'Particle Asset', extensions: ['.particle', '.particle.json'] })
public particleAssetGuid: string = '';
// ============= 基础属性 | Basic Properties =============
// These are used when particleAssetGuid is not set
/** 最大粒子数量 | Maximum particle count */
@Serialize()
@@ -200,10 +216,13 @@ export class ParticleSystemComponent extends Component {
// ============= 渲染属性 | Rendering Properties =============
/** 粒子纹理 | Particle texture */
/**
* 粒子纹理 GUID
* Particle texture GUID
*/
@Serialize()
@Property({ type: 'asset', label: 'Texture', assetType: 'texture' })
public texture: string = '';
public textureGuid: string = '';
/** 粒子尺寸(像素)| Particle size (pixels) */
@Serialize()

View File

@@ -108,8 +108,8 @@ export interface IParticleAsset {
blendMode: ParticleBlendMode;
/** 排序顺序 | Sorting order */
sortingOrder: number;
/** 纹理路径 | Texture path */
texture?: string;
/** 纹理资产 GUID | Texture asset GUID */
textureGuid?: string;
// 模块配置 | Module configurations
/** 模块列表 | Module list */

View File

@@ -191,7 +191,7 @@ export class ParticleRenderDataProvider implements IRenderDataProvider {
colors: this._colors.subarray(0, particleIndex),
tileCount: particleIndex,
sortingOrder,
texturePath: systems[0]?.component.texture || undefined
texturePath: systems[0]?.component.textureGuid || undefined
};
this._renderDataCache.push(renderData);

View File

@@ -65,7 +65,7 @@ export class Canvas2DRenderSystem extends EntitySystem {
this.ctx.translate(x, y);
this.ctx.rotate(rotation);
const texture = this.textureCache.get(sprite.texture || '');
const texture = this.textureCache.get(sprite.textureGuid || '');
if (texture) {
this.ctx.drawImage(texture, -width / 2, -height / 2, width, height);
} else {

View File

@@ -626,7 +626,7 @@ function MaterialOverrideEditor({ sprite, material, onChange }: MaterialOverride
onChange('materialOverrides', newOverrides);
};
if (!sprite.material) {
if (!sprite.materialGuid) {
return null;
}
@@ -871,10 +871,10 @@ function SpriteInspectorContent({ context }: { context: ComponentInspectorContex
const [material, setMaterial] = useState<Material | null>(null);
const [, forceUpdate] = useState({});
// Load material when sprite.material changes.
// 当 sprite.material 变化时加载材质。
// Load material when sprite.materialGuid changes.
// 当 sprite.materialGuid 变化时加载材质。
useEffect(() => {
if (!sprite.material) {
if (!sprite.materialGuid) {
setMaterial(null);
return;
}
@@ -887,7 +887,7 @@ function SpriteInspectorContent({ context }: { context: ComponentInspectorContex
// Try to get cached material by ID.
// 尝试通过 ID 获取缓存的材质。
const materialId = materialManager.getMaterialIdByPath(sprite.material);
const materialId = materialManager.getMaterialIdByPath(sprite.materialGuid);
if (materialId > 0) {
const mat = materialManager.getMaterial(materialId);
setMaterial(mat || null);
@@ -896,7 +896,7 @@ function SpriteInspectorContent({ context }: { context: ComponentInspectorContex
// Load material asynchronously.
// 异步加载材质。
materialManager.loadMaterialFromPath(sprite.material)
materialManager.loadMaterialFromPath(sprite.materialGuid)
.then(matId => {
const mat = materialManager.getMaterial(matId);
setMaterial(mat || null);
@@ -904,7 +904,7 @@ function SpriteInspectorContent({ context }: { context: ComponentInspectorContex
.catch(() => {
setMaterial(null);
});
}, [sprite.material]);
}, [sprite.materialGuid]);
const handleChange = useCallback((propertyName: string, value: unknown) => {
(sprite as unknown as Record<string, unknown>)[propertyName] = value;
@@ -930,7 +930,7 @@ function SpriteInspectorContent({ context }: { context: ComponentInspectorContex
}, []);
// No material selected
if (!sprite.material) {
if (!sprite.materialGuid) {
return null;
}
@@ -940,7 +940,7 @@ function SpriteInspectorContent({ context }: { context: ComponentInspectorContex
{material && (
<InlineMaterialEditor
material={material}
materialPath={sprite.material}
materialPath={sprite.materialGuid}
onMaterialChange={handleMaterialChange}
/>
)}

View File

@@ -5,8 +5,11 @@ import { Component, ECSComponent, Serializable, Serialize, Property } from '@ese
* Animation frame data
*/
export interface AnimationFrame {
/** 纹理路径 | Texture path */
texture: string;
/**
* 纹理资产 GUID
* Texture asset GUID
*/
textureGuid: string;
/** 帧持续时间(秒) | Frame duration in seconds */
duration: number;
/** UV坐标 [u0, v0, u1, v1] | UV coordinates */
@@ -43,7 +46,7 @@ export class SpriteAnimatorComponent extends Component {
@Property({
type: 'animationClips',
label: 'Animation Clips',
controls: [{ component: 'Sprite', property: 'texture' }]
controls: [{ component: 'Sprite', property: 'textureGuid' }]
})
public clips: AnimationClip[] = [];
@@ -101,7 +104,7 @@ export class SpriteAnimatorComponent extends Component {
* Create animation clip from sprite atlas
*
* @param name - 动画名称 | Animation name
* @param texture - 纹理路径 | Texture path
* @param textureGuid - 纹理资产 GUID | Texture asset GUID
* @param frameCount - 帧数 | Number of frames
* @param frameWidth - 每帧宽度 | Frame width
* @param frameHeight - 每帧高度 | Frame height
@@ -112,7 +115,7 @@ export class SpriteAnimatorComponent extends Component {
*/
createClipFromAtlas(
name: string,
texture: string,
textureGuid: string,
frameCount: number,
frameWidth: number,
frameHeight: number,
@@ -132,7 +135,7 @@ export class SpriteAnimatorComponent extends Component {
const y = row * frameHeight;
frames.push({
texture,
textureGuid,
duration,
uv: [
x / atlasWidth,
@@ -159,19 +162,19 @@ export class SpriteAnimatorComponent extends Component {
* Create animation clip from frame sequence
*
* @param name - 动画名称 | Animation name
* @param textures - 纹理路径数组 | Array of texture paths
* @param textureGuids - 纹理资产 GUID 数组 | Array of texture asset GUIDs
* @param fps - 帧率 | Frames per second
* @param loop - 是否循环 | Whether to loop
*/
createClipFromSequence(
name: string,
textures: string[],
textureGuids: string[],
fps: number = 12,
loop: boolean = true
): AnimationClip {
const duration = 1 / fps;
const frames: AnimationFrame[] = textures.map((texture) => ({
texture,
const frames: AnimationFrame[] = textureGuids.map((textureGuid) => ({
textureGuid,
duration
}));

View File

@@ -27,19 +27,20 @@ export type MaterialOverrides = Record<string, MaterialPropertyOverride>;
* Sprite component - manages 2D image rendering
*/
@ECSComponent('Sprite')
@Serializable({ version: 3, typeId: 'Sprite' })
@Serializable({ version: 4, typeId: 'Sprite' })
export class SpriteComponent extends Component {
/** 纹理路径或资源ID | Texture path or asset ID */
@Serialize()
@Property({ type: 'asset', label: 'Texture', assetType: 'texture' })
public texture: string = '';
/**
* 资产GUID(新的资产系统)
* Asset GUID for new asset system
* 纹理资产 GUID
* Texture asset GUID
*
* Stores the unique identifier of the texture asset.
* The actual file path is resolved at runtime via AssetDatabase.
* 存储纹理资产的唯一标识符。
* 实际文件路径在运行时通过 AssetDatabase 解析。
*/
@Serialize()
public assetGuid?: string;
@Property({ type: 'asset', label: 'Texture', assetType: 'texture' })
public textureGuid: string = '';
/**
* 纹理ID运行时使用
@@ -151,15 +152,15 @@ export class SpriteComponent extends Component {
public sortingOrder: number = 0;
/**
* 材质资产路径(共享材质)
* Material asset path (shared material)
* 材质资产 GUID(共享材质)
* Material asset GUID (shared material)
*
* Multiple sprites can reference the same material file.
* 多个精灵可以引用同一个材质文件。
*/
@Serialize()
@Property({ type: 'asset', label: 'Material', extensions: ['.mat'] })
public material: string = '';
public materialGuid: string = '';
/**
* 材质属性覆盖(实例级别)
@@ -215,9 +216,13 @@ export class SpriteComponent extends Component {
this.originY = value;
}
constructor(texture: string = '') {
/**
* @param textureGuidOrPath - Texture GUID or path (for backward compatibility)
*/
constructor(textureGuidOrPath: string = '') {
super();
this.texture = texture;
// Support both GUID and path for backward compatibility
this.textureGuid = textureGuidOrPath;
}
/**
@@ -260,7 +265,7 @@ export class SpriteComponent extends Component {
}
this._assetReference = reference;
if (reference) {
this.assetGuid = reference.guid;
this.textureGuid = reference.guid;
}
}
@@ -292,11 +297,11 @@ export class SpriteComponent extends Component {
}
/**
* 获取有效的纹理源
* Get effective texture source
* 获取纹理 GUID
* Get texture GUID
*/
getTextureSource(): string {
return this.assetGuid || this.texture;
return this.textureGuid;
}
// ============= Material Override Methods =============

View File

@@ -51,7 +51,7 @@ export class SpriteAnimatorSystem extends EntitySystem {
if (sprite) {
const frame = animator.getCurrentFrame();
if (frame) {
sprite.texture = frame.texture;
sprite.textureGuid = frame.textureGuid;
// Update UV if specified
if (frame.uv) {

View File

@@ -61,7 +61,8 @@ export interface UITextConfig extends UIBaseConfig {
* Image configuration
*/
export interface UIImageConfig extends UIBaseConfig {
texture: string | number;
/** 纹理资产 GUID 或运行时 ID | Texture asset GUID or runtime ID */
textureGuid: string | number;
tint?: number;
}
@@ -236,7 +237,7 @@ export class UIBuilder {
const render = entity.addComponent(new UIRenderComponent());
render.type = UIRenderType.Image;
render.texture = config.texture;
render.textureGuid = config.textureGuid;
render.textureTint = config.tint ?? 0xFFFFFF;
return entity;

View File

@@ -96,12 +96,12 @@ export class UIRenderComponent extends Component {
// ===== 纹理 Texture =====
/**
* 纹理路径或 ID
* Texture path or runtime ID
* 纹理资产 GUID 或运行时 ID
* Texture asset GUID or runtime ID
*/
@Serialize()
@Property({ type: 'asset', label: 'Texture', assetType: 'texture' })
public texture: string | number | null = null;
public textureGuid: string | number | null = null;
/**
* 纹理 UV 坐标 (用于图集)
@@ -230,20 +230,25 @@ export class UIRenderComponent extends Component {
/**
* 设置图片
* Set image texture
*
* @param textureGuid - 纹理资产 GUID | Texture asset GUID
*/
public setImage(texture: string | number): this {
public setImage(textureGuid: string | number): this {
this.type = UIRenderType.Image;
this.texture = texture;
this.textureGuid = textureGuid;
return this;
}
/**
* 设置九宫格
* Set nine-patch image
*
* @param textureGuid - 纹理资产 GUID | Texture asset GUID
* @param margins - 九宫格边距 | Nine-patch margins
*/
public setNinePatch(texture: string | number, margins: [number, number, number, number]): this {
public setNinePatch(textureGuid: string | number, margins: [number, number, number, number]): this {
this.type = UIRenderType.NinePatch;
this.texture = texture;
this.textureGuid = textureGuid;
this.ninePatchMargins = margins;
return this;
}

View File

@@ -10,7 +10,8 @@ export interface UIButtonStyle {
textColor: number;
borderColor: number;
borderWidth: number;
texture?: string;
/** 纹理资产 GUID | Texture asset GUID */
textureGuid?: string;
}
/**
@@ -51,36 +52,36 @@ export class UIButtonComponent extends Component {
// ===== 状态纹理 State Textures =====
/**
* 正常状态纹理
* Normal state texture
* 正常状态纹理 GUID
* Normal state texture GUID
*/
@Serialize()
@Property({ type: 'asset', label: 'Normal Texture', assetType: 'texture' })
public normalTexture: string = '';
public normalTextureGuid: string = '';
/**
* 悬停状态纹理
* Hover state texture
* 悬停状态纹理 GUID
* Hover state texture GUID
*/
@Serialize()
@Property({ type: 'asset', label: 'Hover Texture', assetType: 'texture' })
public hoverTexture: string = '';
public hoverTextureGuid: string = '';
/**
* 按下状态纹理
* Pressed state texture
* 按下状态纹理 GUID
* Pressed state texture GUID
*/
@Serialize()
@Property({ type: 'asset', label: 'Pressed Texture', assetType: 'texture' })
public pressedTexture: string = '';
public pressedTextureGuid: string = '';
/**
* 禁用状态纹理
* Disabled state texture
* 禁用状态纹理 GUID
* Disabled state texture GUID
*/
@Serialize()
@Property({ type: 'asset', label: 'Disabled Texture', assetType: 'texture' })
public disabledTexture: string = '';
public disabledTextureGuid: string = '';
// ===== 状态样式 State Styles =====
@@ -245,16 +246,16 @@ export class UIButtonComponent extends Component {
}
/**
* 获取当前应该显示的纹理
* Get the texture that should be displayed based on state
* 获取当前应该显示的纹理 GUID
* Get the texture GUID that should be displayed based on state
*/
public getStateTexture(state: 'disabled' | 'pressed' | 'hovered' | 'focused' | 'normal'): string {
if (this.disabled && this.disabledTexture) return this.disabledTexture;
public getStateTextureGuid(state: 'disabled' | 'pressed' | 'hovered' | 'focused' | 'normal'): string {
if (this.disabled && this.disabledTextureGuid) return this.disabledTextureGuid;
switch (state) {
case 'pressed': return this.pressedTexture || this.normalTexture;
case 'hovered': return this.hoverTexture || this.normalTexture;
case 'focused': return this.normalTexture;
default: return this.normalTexture;
case 'pressed': return this.pressedTextureGuid || this.normalTextureGuid;
case 'hovered': return this.hoverTextureGuid || this.normalTextureGuid;
case 'focused': return this.normalTextureGuid;
default: return this.normalTextureGuid;
}
}
@@ -263,7 +264,7 @@ export class UIButtonComponent extends Component {
* Whether to use texture for rendering
*/
public useTexture(): boolean {
return (this.displayMode === 'texture' || this.displayMode === 'both') && !!this.normalTexture;
return (this.displayMode === 'texture' || this.displayMode === 'both') && !!this.normalTextureGuid;
}
/**
@@ -297,14 +298,14 @@ export class UIButtonComponent extends Component {
}
/**
* 设置纹理
* Set textures for different states
* 设置纹理 GUID
* Set texture GUIDs for different states
*/
public setTextures(normal: string, hover?: string, pressed?: string, disabled?: string): this {
this.normalTexture = normal;
if (hover) this.hoverTexture = hover;
if (pressed) this.pressedTexture = pressed;
if (disabled) this.disabledTexture = disabled;
public setTextureGuids(normalGuid: string, hoverGuid?: string, pressedGuid?: string, disabledGuid?: string): this {
this.normalTextureGuid = normalGuid;
if (hoverGuid) this.hoverTextureGuid = hoverGuid;
if (pressedGuid) this.pressedTextureGuid = pressedGuid;
if (disabledGuid) this.disabledTextureGuid = disabledGuid;
this.displayMode = 'texture';
return this;
}

View File

@@ -66,8 +66,8 @@ export class UIButtonRenderSystem extends EntitySystem {
// Render texture if in texture or both mode
// 如果在纹理或两者模式下,渲染纹理
if (button.useTexture()) {
const texture = button.getStateTexture('normal');
if (texture) {
const textureGuid = button.getStateTextureGuid('normal');
if (textureGuid) {
collector.addRect(
renderX, renderY,
width, height,
@@ -78,7 +78,7 @@ export class UIButtonRenderSystem extends EntitySystem {
rotation,
pivotX,
pivotY,
texturePath: texture
texturePath: textureGuid
}
);
}

View File

@@ -96,9 +96,9 @@ export class UIRectRenderSystem extends EntitySystem {
// Render texture if present
// 如果有纹理,渲染纹理
if (render.texture) {
const texturePath = typeof render.texture === 'string' ? render.texture : undefined;
const textureId = typeof render.texture === 'number' ? render.texture : undefined;
if (render.textureGuid) {
const texturePath = typeof render.textureGuid === 'string' ? render.textureGuid : undefined;
const textureId = typeof render.textureGuid === 'number' ? render.textureGuid : undefined;
collector.addRect(
renderX, renderY,