diff --git a/packages/editor-app/src/api/tauri.ts b/packages/editor-app/src/api/tauri.ts index 626c678f..c1899c7d 100644 --- a/packages/editor-app/src/api/tauri.ts +++ b/packages/editor-app/src/api/tauri.ts @@ -267,6 +267,17 @@ export class TauriAPI { return await invoke('copy_file', { src, dst }); } + /** + * 获取文件修改时间 + * Get file modification time + * + * @param path 文件路径 | File path + * @returns 文件修改时间(毫秒时间戳)| File modification time (milliseconds timestamp) + */ + static async getFileMtime(path: string): Promise { + return await invoke('get_file_mtime', { path }); + } + /** * 写入二进制文件 * @param filePath 文件路径 diff --git a/packages/editor-app/src/application/commands/prefab/InstantiatePrefabCommand.ts b/packages/editor-app/src/application/commands/prefab/InstantiatePrefabCommand.ts index 374b9739..2b3b8722 100644 --- a/packages/editor-app/src/application/commands/prefab/InstantiatePrefabCommand.ts +++ b/packages/editor-app/src/application/commands/prefab/InstantiatePrefabCommand.ts @@ -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 + // GlobalComponentRegistry.getAllComponentNames() returns Map // We need to cast it to Map - const componentRegistry = ComponentRegistry.getAllComponentNames() as Map; + const componentRegistry = GlobalComponentRegistry.getAllComponentNames() as Map; // 实例化预制体 | Instantiate prefab this.createdEntity = PrefabSerializer.instantiate( diff --git a/packages/editor-app/src/components/inspectors/views/AssetFileInspector.tsx b/packages/editor-app/src/components/inspectors/views/AssetFileInspector.tsx index 66bdbbb5..46f905f7 100644 --- a/packages/editor-app/src/components/inspectors/views/AssetFileInspector.tsx +++ b/packages/editor-app/src/components/inspectors/views/AssetFileInspector.tsx @@ -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(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 ( +
+ {/* Slice Preview Canvas */} +
+ + {imageSize && ( +
+ {imageSize.width} × {imageSize.height} px +
+ )} +
+ + {/* Slice Border Inputs */} +
+ {sliceBorder.map((value, index) => ( +
+ + 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' }} + /> +
+ ))} +
+
+ ); +} + 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(null); const [isUpdating, setIsUpdating] = useState(false); + // State for sprite settings (nine-patch borders) + // 精灵设置状态(九宫格边框) + const [spriteSettings, setSpriteSettings] = useState(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 (
@@ -228,6 +433,23 @@ export function AssetFileInspector({ fileInfo, content, isImage }: AssetFileInsp
)} + {/* Sprite Settings Section - only for image files */} + {/* 精灵设置部分 - 仅用于图像文件 */} + {isImage && ( +
+
+ + 九宫格设置 (Nine-Patch) +
+ +
+ )} + {content && (
文件预览
diff --git a/packages/editor-app/src/infrastructure/field-editors/VectorFieldEditors.tsx b/packages/editor-app/src/infrastructure/field-editors/VectorFieldEditors.tsx index 05e229ec..357a791c 100644 --- a/packages/editor-app/src/infrastructure/field-editors/VectorFieldEditors.tsx +++ b/packages/editor-app/src/infrastructure/field-editors/VectorFieldEditors.tsx @@ -141,7 +141,27 @@ export class Vector4FieldEditor implements IFieldEditor { } render({ label, value, onChange, context }: FieldEditorProps): 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 (
@@ -150,28 +170,28 @@ export class Vector4FieldEditor implements IFieldEditor { onChange({ ...v, x })} + onChange={(x) => handleChange({ ...v, x })} readonly={context.readonly} axis="x" /> onChange({ ...v, y })} + onChange={(y) => handleChange({ ...v, y })} readonly={context.readonly} axis="y" /> onChange({ ...v, z })} + onChange={(z) => handleChange({ ...v, z })} readonly={context.readonly} axis="z" /> onChange({ ...v, w })} + onChange={(w) => handleChange({ ...v, w })} readonly={context.readonly} axis="w" /> diff --git a/packages/editor-app/src/services/EditorEngineSync.ts b/packages/editor-app/src/services/EditorEngineSync.ts index 1028a323..a8861d23 100644 --- a/packages/editor-app/src/services/EditorEngineSync.ts +++ b/packages/editor-app/src/services/EditorEngineSync.ts @@ -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); + } + } } /** diff --git a/packages/editor-app/src/services/EngineService.ts b/packages/editor-app/src/services/EngineService.ts index 2c13ef94..ed17b45d 100644 --- a/packages/editor-app/src/services/EngineService.ts +++ b/packages/editor-app/src/services/EngineService.ts @@ -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 { + const scene = this._runtime?.scene; + if (!this._sceneResourceManager || !scene) { + return; + } + await this._sceneResourceManager.loadSceneResources(scene); + } + /** * Check if a snapshot exists. */