From 568b32742551afb99078a4341096f1ca302d9db9 Mon Sep 17 00:00:00 2001 From: YHH <359807859@qq.com> Date: Sun, 7 Dec 2025 01:00:35 +0800 Subject: [PATCH] =?UTF-8?q?fix(particle):=20=E4=BF=AE=E5=A4=8D=E7=B2=92?= =?UTF-8?q?=E5=AD=90=E7=B3=BB=E7=BB=9F=E5=9C=A8=E6=B5=8F=E8=A7=88=E5=99=A8?= =?UTF-8?q?=E9=A2=84=E8=A7=88=E4=B8=AD=E7=9A=84=E8=B5=84=E4=BA=A7=E5=8A=A0?= =?UTF-8?q?=E8=BD=BD=E5=92=8C=E6=B8=B2=E6=9F=93=20(#290)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(editor): 修复粒子实体创建和优化检视器 - 添加 effects 分类到右键菜单,修复粒子实体无法创建的问题 - 添加粒子效果的本地化标签 - 简化粒子组件检视器,优先显示资产文件选择 - 高级属性只在未选择资产时显示,且默认折叠 - 添加可折叠的属性分组提升用户体验 * fix(particle): 修复粒子系统在浏览器预览中的资产加载和渲染 - 添加粒子 Gizmo 支持,显示发射形状并响应 Transform 缩放/旋转 - 修复资产热重载:添加 reloadAsset() 方法和 assets:refresh 事件监听 - 修复 VectorFieldEditors 数值输入精度(step 改为 0.01) - 修复浏览器预览中粒子资产加载失败的问题: - 将相对路径转换为绝对路径以正确复制资产文件 - 使用原始 GUID 而非生成的 GUID 构建 asset catalog - 初始化全局 assetManager 单例的 catalog 和 loader - 在 GameRuntime 的 systemContext 中添加 engineIntegration - 公开 AssetManager.initializeFromCatalog 方法供运行时使用 --- .../asset-system/src/core/AssetManager.ts | 13 +- .../src/interfaces/IAssetManager.ts | 19 +- .../src/components/SceneHierarchy.tsx | 5 +- .../editor-app/src/components/Viewport.tsx | 68 +- .../field-editors/VectorFieldEditors.tsx | 53 +- .../src/ParticleEditorModule.ts | 56 +- .../src/gizmos/ParticleGizmo.ts | 310 ++++++ packages/particle-editor/src/index.ts | 3 + .../src/panels/ParticleEditorPanel.tsx | 19 +- .../providers/ParticleInspectorProvider.tsx | 451 ++------- .../src/stores/ParticleEditorStore.ts | 12 +- packages/particle/package.json | 4 +- packages/particle/src/ParticleEmitter.ts | 87 +- .../particle/src/ParticleRuntimeModule.ts | 56 +- .../particle/src/ParticleSystemComponent.ts | 889 +++++++++++------- .../particle/src/loaders/ParticleLoader.ts | 21 + packages/particle/src/loaders/index.ts | 3 +- .../rendering/ParticleRenderDataProvider.ts | 8 +- .../particle/src/systems/ParticleSystem.ts | 280 +++++- packages/platform-web/src/BrowserRuntime.ts | 49 +- packages/runtime-core/src/GameRuntime.ts | 1 + pnpm-lock.yaml | 3 +- 22 files changed, 1628 insertions(+), 782 deletions(-) create mode 100644 packages/particle-editor/src/gizmos/ParticleGizmo.ts diff --git a/packages/asset-system/src/core/AssetManager.ts b/packages/asset-system/src/core/AssetManager.ts index e9e38e25..a77c35af 100644 --- a/packages/asset-system/src/core/AssetManager.ts +++ b/packages/asset-system/src/core/AssetManager.ts @@ -104,11 +104,22 @@ export class AssetManager implements IAssetManager { return this._database; } + /** + * Get the loader factory. + * 获取加载器工厂。 + */ + getLoaderFactory(): AssetLoaderFactory { + return this._loaderFactory as AssetLoaderFactory; + } + /** * Initialize from catalog * 从目录初始化 + * + * Can be called after construction to load catalog entries. + * 可在构造后调用以加载目录条目。 */ - private initializeFromCatalog(catalog: IAssetCatalog): void { + initializeFromCatalog(catalog: IAssetCatalog): void { catalog.entries.forEach((entry, guid) => { const metadata: IAssetMetadata = { guid, diff --git a/packages/asset-system/src/interfaces/IAssetManager.ts b/packages/asset-system/src/interfaces/IAssetManager.ts index 7523b796..0eba8bfe 100644 --- a/packages/asset-system/src/interfaces/IAssetManager.ts +++ b/packages/asset-system/src/interfaces/IAssetManager.ts @@ -12,9 +12,11 @@ import { IAssetLoadResult, IAssetReferenceInfo, IAssetPreloadGroup, - IAssetLoadProgress + IAssetLoadProgress, + IAssetCatalog } from '../types/AssetTypes'; import { IAssetLoader } from './IAssetLoader'; +import { IAssetReader } from './IAssetReader'; /** * Asset manager interface @@ -150,6 +152,21 @@ export interface IAssetManager { * 释放管理器 */ dispose(): void; + + /** + * Set asset reader + * 设置资产读取器 + */ + setReader(reader: IAssetReader): void; + + /** + * Initialize from catalog + * 从目录初始化 + * + * Loads asset metadata from a catalog for runtime asset resolution. + * 从目录加载资产元数据,用于运行时资产解析。 + */ + initializeFromCatalog(catalog: IAssetCatalog): void; } /** diff --git a/packages/editor-app/src/components/SceneHierarchy.tsx b/packages/editor-app/src/components/SceneHierarchy.tsx index b8525cf9..d4fd8393 100644 --- a/packages/editor-app/src/components/SceneHierarchy.tsx +++ b/packages/editor-app/src/components/SceneHierarchy.tsx @@ -28,6 +28,7 @@ function getIconComponent(iconName: string | undefined, size: number = 14): Reac const categoryIconMap: Record = { 'rendering': 'Image', 'ui': 'LayoutGrid', + 'effects': 'Sparkles', 'physics': 'Box', 'audio': 'Volume2', 'basic': 'Plus', @@ -925,6 +926,7 @@ function ContextMenuWithSubmenu({ 'ui': { zh: 'UI', en: 'UI' }, 'physics': { zh: '物理', en: 'Physics' }, 'audio': { zh: '音频', en: 'Audio' }, + 'effects': { zh: '特效', en: 'Effects' }, 'other': { zh: '其他', en: 'Other' }, }; @@ -934,6 +936,7 @@ function ContextMenuWithSubmenu({ 'Animated Sprite': { zh: '动画精灵', en: 'Animated Sprite' }, '创建 Tilemap': { zh: '瓦片地图', en: 'Tilemap' }, 'Camera 2D': { zh: '2D 相机', en: 'Camera 2D' }, + '创建粒子效果': { zh: '粒子效果', en: 'Particle Effect' }, }; const getCategoryLabel = (category: string) => { @@ -966,7 +969,7 @@ function ContextMenuWithSubmenu({ setActiveSubmenu(category); }; - const categoryOrder = ['rendering', 'ui', 'physics', 'audio', 'basic', 'other']; + const categoryOrder = ['rendering', 'ui', 'effects', 'physics', 'audio', 'basic', 'other']; const sortedCategories = Object.entries(templatesByCategory).sort(([a], [b]) => { const orderA = categoryOrder.indexOf(a); const orderB = categoryOrder.indexOf(b); diff --git a/packages/editor-app/src/components/Viewport.tsx b/packages/editor-app/src/components/Viewport.tsx index e5a213ee..e13587dd 100644 --- a/packages/editor-app/src/components/Viewport.tsx +++ b/packages/editor-app/src/components/Viewport.tsx @@ -843,6 +843,12 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) { // 从场景中收集所有资产路径 const sceneObj = JSON.parse(sceneData); const assetPaths = new Set(); + // GUID 到路径的映射,用于需要通过 GUID 加载的资产 + // GUID to path mapping for assets that need to be loaded by GUID + const guidToPath = new Map(); + + // Get asset registry for resolving GUIDs + const assetRegistry = Core.services.tryResolve(AssetRegistryService); // Scan all components for asset references if (sceneObj.entities) { @@ -865,6 +871,46 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) { if (comp.type === 'AudioSource' && comp.data?.clip) { assetPaths.add(comp.data.clip); } + // Particle assets - resolve GUID to path + if (comp.type === 'ParticleSystem' && comp.data?.particleAssetGuid) { + const guid = comp.data.particleAssetGuid; + if (assetRegistry) { + const relativePath = assetRegistry.getPathByGuid(guid); + if (relativePath && projectPath) { + // Convert relative path to absolute path + // 将相对路径转换为绝对路径 + const absolutePath = `${projectPath}\\${relativePath.replace(/\//g, '\\')}`; + assetPaths.add(absolutePath); + guidToPath.set(guid, absolutePath); + + // Also check for texture referenced in particle asset + // 同时检查粒子资产中引用的纹理 + try { + const particleContent = await TauriAPI.readFileContent(absolutePath); + const particleData = JSON.parse(particleContent); + const textureRef = particleData.textureGuid || particleData.texturePath; + if (textureRef) { + // Check if it's a GUID or a path + if (textureRef.includes('-') && textureRef.length > 30) { + // Looks like a GUID + const textureRelPath = assetRegistry.getPathByGuid(textureRef); + if (textureRelPath && projectPath) { + const textureAbsPath = `${projectPath}\\${textureRelPath.replace(/\//g, '\\')}`; + assetPaths.add(textureAbsPath); + guidToPath.set(textureRef, textureAbsPath); + } + } else { + // It's a path + const textureAbsPath = `${projectPath}\\${textureRef.replace(/\//g, '\\')}`; + assetPaths.add(textureAbsPath); + } + } + } catch { + // Ignore parse errors + } + } + } + } } } } @@ -899,12 +945,25 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) { '.btree': 'btree', '.tmx': 'tilemap', '.tsx': 'tileset', '.mp3': 'audio', '.ogg': 'audio', '.wav': 'audio', - '.json': 'json' + '.json': 'json', + '.particle': 'particle' }; const assetType = typeMap[ext] || 'binary'; - // Generate simple GUID based on path - const guid = assetPath.replace(/[^a-zA-Z0-9]/g, '-').substring(0, 36); + // Check if this asset was referenced by a GUID (e.g., particle assets) + // If so, use the original GUID; otherwise generate one from the path + // 检查此资产是否通过 GUID 引用(如粒子资产) + // 如果是,使用原始 GUID;否则根据路径生成 + let guid: string | undefined; + for (const [originalGuid, mappedPath] of guidToPath.entries()) { + if (mappedPath === assetPath) { + guid = originalGuid; + break; + } + } + if (!guid) { + guid = assetPath.replace(/[^a-zA-Z0-9]/g, '-').substring(0, 36); + } catalogEntries[guid] = { guid, @@ -913,8 +972,6 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) { size: 0, hash: '' }; - - console.log(`[Viewport] Copied asset: ${filename}`); } catch (error) { console.error(`[Viewport] Failed to copy asset ${assetPath}:`, error); } @@ -928,7 +985,6 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) { entries: catalogEntries }; await TauriAPI.writeFileContent(`${runtimeDir}/asset-catalog.json`, JSON.stringify(assetCatalog, null, 2)); - console.log(`[Viewport] Asset catalog created with ${Object.keys(catalogEntries).length} entries`); // Copy user-runtime.js if it exists // 如果存在用户运行时,复制 user-runtime.js diff --git a/packages/editor-app/src/infrastructure/field-editors/VectorFieldEditors.tsx b/packages/editor-app/src/infrastructure/field-editors/VectorFieldEditors.tsx index 8d2d918b..05e229ec 100644 --- a/packages/editor-app/src/infrastructure/field-editors/VectorFieldEditors.tsx +++ b/packages/editor-app/src/infrastructure/field-editors/VectorFieldEditors.tsx @@ -11,19 +11,46 @@ const VectorInput: React.FC<{ onChange: (value: number) => void; readonly?: boolean; axis: 'x' | 'y' | 'z' | 'w'; -}> = ({ label, value, onChange, readonly, axis }) => ( -
- {label} - onChange(parseFloat(e.target.value) || 0)} - disabled={readonly} - step={0.1} - className="property-input property-input-number property-input-number-compact" - /> -
-); + step?: number; +}> = ({ label, value, onChange, readonly, axis, step = 0.01 }) => { + const handleChange = (e: React.ChangeEvent) => { + const inputValue = e.target.value; + // 允许空字符串、负号、小数点等中间输入状态 + // Allow empty string, minus sign, decimal point as intermediate states + if (inputValue === '' || inputValue === '-' || inputValue === '.' || inputValue === '-.') { + return; // 不触发 onChange,等待用户完成输入 + } + const parsed = parseFloat(inputValue); + if (!isNaN(parsed)) { + onChange(parsed); + } + }; + + const handleBlur = (e: React.FocusEvent) => { + // 失去焦点时,如果是无效值则重置为当前值 + // On blur, if value is invalid, reset to current value + const parsed = parseFloat(e.target.value); + if (isNaN(parsed)) { + e.target.value = String(value); + } + }; + + return ( +
+ {label} + +
+ ); +}; export class Vector2FieldEditor implements IFieldEditor { readonly type = 'vector2'; diff --git a/packages/particle-editor/src/ParticleEditorModule.ts b/packages/particle-editor/src/ParticleEditorModule.ts index 9ecb1114..3f673c6d 100644 --- a/packages/particle-editor/src/ParticleEditorModule.ts +++ b/packages/particle-editor/src/ParticleEditorModule.ts @@ -35,6 +35,7 @@ import { import { ParticleEditorPanel } from './panels/ParticleEditorPanel'; import { ParticleInspectorProvider } from './providers/ParticleInspectorProvider'; import { useParticleEditorStore } from './stores/ParticleEditorStore'; +import { registerParticleGizmo, unregisterParticleGizmo } from './gizmos/ParticleGizmo'; // 导入编辑器 CSS 样式(会被 vite 自动处理并注入到 DOM) // Import editor CSS styles (automatically handled and injected by vite) @@ -45,6 +46,8 @@ import './styles/ParticleEditor.css'; * Particle Editor Module */ export class ParticleEditorModule implements IEditorModuleLoader { + private _assetsRefreshUnsubscribe: (() => void) | null = null; + async install(services: ServiceContainer): Promise { // 注册检视器提供者 | Register inspector provider const inspectorRegistry = services.resolve(InspectorRegistry); @@ -72,10 +75,61 @@ export class ParticleEditorModule implements IEditorModuleLoader { createMessage: 'particle:create-asset' }); } + + // 注册 Gizmo | Register gizmo + registerParticleGizmo(); + + // 监听资产刷新事件,当 .particle 文件保存时重新加载所有粒子组件 + // Listen for assets refresh event to reload particle components when .particle files are saved + const messageHub = services.resolve(MessageHub); + if (messageHub) { + this._assetsRefreshUnsubscribe = messageHub.subscribe('assets:refresh', () => { + this._reloadAllParticleAssets(); + }); + } } async uninstall(): Promise { - // 清理 | Clean up + // 取消订阅事件 | Unsubscribe events + if (this._assetsRefreshUnsubscribe) { + this._assetsRefreshUnsubscribe(); + this._assetsRefreshUnsubscribe = null; + } + + // 取消注册 Gizmo | Unregister gizmo + unregisterParticleGizmo(); + } + + /** + * 重新加载所有粒子资产 + * Reload all particle assets + * + * 当资产文件变化时调用,强制所有粒子组件重新加载资产。 + * Called when asset files change, forcing all particle components to reload. + */ + private _reloadAllParticleAssets(): void { + const scene = Core.scene; + if (!scene) return; + + // 遍历所有带有 ParticleSystemComponent 的实体 + // Iterate all entities with ParticleSystemComponent + scene.entities.forEach((entity: Entity) => { + const particle = entity.getComponent(ParticleSystemComponent) as ParticleSystemComponent | null; + if (particle && particle.particleAssetGuid) { + // 异步重新加载资产 | Async reload asset + particle.reloadAsset().then((success: boolean) => { + if (success) { + console.log(`[ParticleEditorModule] Reloaded particle asset for entity: ${entity.name}`); + // 标记需要重建并重新播放 | Mark dirty and replay + particle.markDirty(); + if (particle.isPlaying) { + particle.stop(true); + particle.play(); + } + } + }); + } + }); } getPanels(): PanelDescriptor[] { diff --git a/packages/particle-editor/src/gizmos/ParticleGizmo.ts b/packages/particle-editor/src/gizmos/ParticleGizmo.ts new file mode 100644 index 00000000..6ce4d8d7 --- /dev/null +++ b/packages/particle-editor/src/gizmos/ParticleGizmo.ts @@ -0,0 +1,310 @@ +/** + * Particle System Gizmo Implementation + * 粒子系统 Gizmo 实现 + * + * 显示粒子发射区域形状,支持 Transform 缩放和旋转。 + * Displays particle emission shape, supports Transform scale and rotation. + */ + +import type { Entity } from '@esengine/ecs-framework'; +import type { + IGizmoRenderData, + IRectGizmoData, + ICircleGizmoData, + ILineGizmoData, + GizmoColor +} from '@esengine/editor-core'; +import { GizmoRegistry } from '@esengine/editor-core'; +import { TransformComponent } from '@esengine/engine-core'; +import { ParticleSystemComponent, EmissionShape } from '@esengine/particle'; + +/** + * 粒子 Gizmo 颜色配置 + * Particle gizmo color configuration + */ +const ParticleGizmoColors = { + emissionShape: { r: 1.0, g: 0.6, b: 0.0, a: 0.8 } as GizmoColor, + emissionShapeSelected: { r: 1.0, g: 0.8, b: 0.2, a: 1.0 } as GizmoColor, + direction: { r: 0.0, g: 0.8, b: 1.0, a: 0.9 } as GizmoColor, + centerMark: { r: 1.0, g: 1.0, b: 1.0, a: 0.8 } as GizmoColor, +}; + +/** + * 创建中心点标记 Gizmo (十字形) + * Create center mark gizmo (cross shape) + */ +function createCenterMarkGizmo(x: number, y: number, size: number, color: GizmoColor): ILineGizmoData[] { + const halfSize = size / 2; + return [ + { + type: 'line', + points: [ + { x: x - halfSize, y: y }, + { x: x + halfSize, y: y } + ], + color, + closed: false + }, + { + type: 'line', + points: [ + { x: x, y: y - halfSize }, + { x: x, y: y + halfSize } + ], + color, + closed: false + } + ]; +} + +/** + * 创建方向箭头 + * Create direction arrow + */ +function createDirectionArrow( + x: number, + y: number, + direction: number, + length: number, + color: GizmoColor +): ILineGizmoData[] { + const endX = x + Math.cos(direction) * length; + const endY = y + Math.sin(direction) * length; + const arrowSize = length * 0.2; + const arrowAngle = Math.PI / 6; + + return [ + // 主线 + { + type: 'line', + points: [ + { x, y }, + { x: endX, y: endY } + ], + color, + closed: false + }, + // 箭头左 + { + type: 'line', + points: [ + { x: endX, y: endY }, + { + x: endX - arrowSize * Math.cos(direction - arrowAngle), + y: endY - arrowSize * Math.sin(direction - arrowAngle) + } + ], + color, + closed: false + }, + // 箭头右 + { + type: 'line', + points: [ + { x: endX, y: endY }, + { + x: endX - arrowSize * Math.cos(direction + arrowAngle), + y: endY - arrowSize * Math.sin(direction + arrowAngle) + } + ], + color, + closed: false + } + ]; +} + +/** + * ParticleSystemComponent gizmo provider + * 粒子系统组件 gizmo 提供者 + */ +function particleSystemGizmoProvider( + particle: ParticleSystemComponent, + entity: Entity, + isSelected: boolean +): IGizmoRenderData[] { + const transform = entity.getComponent(TransformComponent); + if (!transform) return []; + + const gizmos: IGizmoRenderData[] = []; + const color = isSelected + ? ParticleGizmoColors.emissionShapeSelected + : ParticleGizmoColors.emissionShape; + + // 获取 Transform 数据 | Get transform data + const worldX = transform.worldPosition?.x ?? transform.position.x; + const worldY = transform.worldPosition?.y ?? transform.position.y; + + const rot = transform.worldRotation ?? transform.rotation; + const worldRotation = rot?.z ?? 0; + + const scale = transform.worldScale ?? transform.scale; + const scaleX = scale?.x ?? 1; + const scaleY = scale?.y ?? 1; + + // 从加载的资产获取发射形状配置 | Get emission shape config from loaded asset + const asset = particle.loadedAsset; + const emissionShape = asset?.emissionShape ?? EmissionShape.Point; + const shapeRadius = (asset?.shapeRadius ?? 0) * Math.max(scaleX, scaleY); + const shapeWidth = (asset?.shapeWidth ?? 0) * scaleX; + const shapeHeight = (asset?.shapeHeight ?? 0) * scaleY; + const shapeAngle = (asset?.shapeAngle ?? 30) * Math.PI / 180; // 转换为弧度 + const direction = ((asset?.direction ?? 90) * Math.PI / 180) + worldRotation; // 转换为弧度并应用世界旋转 + + // 根据发射形状绘制 Gizmo | Draw gizmo based on emission shape + switch (emissionShape) { + case EmissionShape.Point: + // 点发射:显示中心点和方向 | Point: show center and direction + gizmos.push(...createCenterMarkGizmo(worldX, worldY, 10, color)); + if (isSelected) { + gizmos.push(...createDirectionArrow(worldX, worldY, direction, 30, ParticleGizmoColors.direction)); + } + break; + + case EmissionShape.Circle: + // 填充圆形:显示圆形区域 | Filled circle: show circle area + gizmos.push({ + type: 'circle', + x: worldX, + y: worldY, + radius: shapeRadius || 20, + color + } as ICircleGizmoData); + if (isSelected) { + gizmos.push(...createCenterMarkGizmo(worldX, worldY, 8, ParticleGizmoColors.centerMark)); + gizmos.push(...createDirectionArrow(worldX, worldY, direction, shapeRadius + 20 || 40, ParticleGizmoColors.direction)); + } + break; + + case EmissionShape.Ring: + // 圆环:显示圆环边缘 | Ring: show ring edge + gizmos.push({ + type: 'circle', + x: worldX, + y: worldY, + radius: shapeRadius || 20, + color + } as ICircleGizmoData); + if (isSelected) { + gizmos.push(...createCenterMarkGizmo(worldX, worldY, 8, ParticleGizmoColors.centerMark)); + } + break; + + case EmissionShape.Rectangle: + // 矩形:显示矩形区域 | Rectangle: show rect area + gizmos.push({ + type: 'rect', + x: worldX, + y: worldY, + width: shapeWidth || 40, + height: shapeHeight || 20, + rotation: worldRotation, + originX: 0.5, + originY: 0.5, + color, + showHandles: false + } as IRectGizmoData); + if (isSelected) { + gizmos.push(...createCenterMarkGizmo(worldX, worldY, 8, ParticleGizmoColors.centerMark)); + gizmos.push(...createDirectionArrow(worldX, worldY, direction, Math.max(shapeWidth, shapeHeight) / 2 + 20 || 40, ParticleGizmoColors.direction)); + } + break; + + case EmissionShape.Edge: + // 矩形边缘:显示矩形边框 | Rectangle edge: show rect border + gizmos.push({ + type: 'rect', + x: worldX, + y: worldY, + width: shapeWidth || 40, + height: shapeHeight || 20, + rotation: worldRotation, + originX: 0.5, + originY: 0.5, + color, + showHandles: false + } as IRectGizmoData); + if (isSelected) { + gizmos.push(...createCenterMarkGizmo(worldX, worldY, 8, ParticleGizmoColors.centerMark)); + } + break; + + case EmissionShape.Line: + // 线段:显示发射线 | Line: show emission line + { + const halfWidth = (shapeWidth || 40) / 2; + const cos = Math.cos(direction + Math.PI / 2); + const sin = Math.sin(direction + Math.PI / 2); + gizmos.push({ + type: 'line', + points: [ + { x: worldX + cos * halfWidth, y: worldY + sin * halfWidth }, + { x: worldX - cos * halfWidth, y: worldY - sin * halfWidth } + ], + color, + closed: false + } as ILineGizmoData); + if (isSelected) { + gizmos.push(...createCenterMarkGizmo(worldX, worldY, 8, ParticleGizmoColors.centerMark)); + gizmos.push(...createDirectionArrow(worldX, worldY, direction, halfWidth + 20, ParticleGizmoColors.direction)); + } + } + break; + + case EmissionShape.Cone: + // 圆锥/扇形:显示扇形区域 | Cone/fan: show fan area + { + const radius = shapeRadius || 30; + const halfAngle = shapeAngle / 2; + const startAngle = direction - halfAngle; + const endAngle = direction + halfAngle; + + // 绘制扇形的两条边和弧线 | Draw two edges and arc of the fan + const segments = 16; + const points: { x: number; y: number }[] = [{ x: worldX, y: worldY }]; + for (let i = 0; i <= segments; i++) { + const angle = startAngle + (endAngle - startAngle) * (i / segments); + points.push({ + x: worldX + Math.cos(angle) * radius, + y: worldY + Math.sin(angle) * radius + }); + } + points.push({ x: worldX, y: worldY }); + + gizmos.push({ + type: 'line', + points, + color, + closed: true + } as ILineGizmoData); + + if (isSelected) { + gizmos.push(...createCenterMarkGizmo(worldX, worldY, 8, ParticleGizmoColors.centerMark)); + gizmos.push(...createDirectionArrow(worldX, worldY, direction, radius + 20, ParticleGizmoColors.direction)); + } + } + break; + + default: + // 默认:显示中心点 | Default: show center point + gizmos.push(...createCenterMarkGizmo(worldX, worldY, 10, color)); + break; + } + + return gizmos; +} + +/** + * Register gizmo provider for ParticleSystemComponent. + * 为 ParticleSystemComponent 注册 gizmo 提供者。 + */ +export function registerParticleGizmo(): void { + GizmoRegistry.register(ParticleSystemComponent, particleSystemGizmoProvider); +} + +/** + * Unregister gizmo provider for ParticleSystemComponent. + * 取消注册 ParticleSystemComponent 的 gizmo 提供者。 + */ +export function unregisterParticleGizmo(): void { + GizmoRegistry.unregister(ParticleSystemComponent); +} diff --git a/packages/particle-editor/src/index.ts b/packages/particle-editor/src/index.ts index 7d5bdb4d..0e2eb61c 100644 --- a/packages/particle-editor/src/index.ts +++ b/packages/particle-editor/src/index.ts @@ -23,3 +23,6 @@ export type { ParticleEditorState } from './stores/ParticleEditorStore'; // Components export { GradientEditor, CurveEditor } from './components'; + +// Gizmos +export { registerParticleGizmo, unregisterParticleGizmo } from './gizmos/ParticleGizmo'; diff --git a/packages/particle-editor/src/panels/ParticleEditorPanel.tsx b/packages/particle-editor/src/panels/ParticleEditorPanel.tsx index 92577702..44862444 100644 --- a/packages/particle-editor/src/panels/ParticleEditorPanel.tsx +++ b/packages/particle-editor/src/panels/ParticleEditorPanel.tsx @@ -2003,6 +2003,18 @@ export function ParticleEditorPanel() { } }, [particleData, filePath, setFilePath, markSaved]); + // 面板容器 ref | Panel container ref + const panelRef = useRef(null); + + // 键盘快捷键处理 | Keyboard shortcut handler + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + if ((e.ctrlKey || e.metaKey) && e.key === 's') { + e.preventDefault(); + e.stopPropagation(); + handleSave(); + } + }, [handleSave]); + const handleOpen = useCallback(async () => { const dialog = Core.services.tryResolve(IDialogService) as IDialog | null; if (!dialog) return; @@ -2130,7 +2142,12 @@ export function ParticleEditorPanel() { } return ( -
+
{/* Toolbar */}
diff --git a/packages/particle-editor/src/providers/ParticleInspectorProvider.tsx b/packages/particle-editor/src/providers/ParticleInspectorProvider.tsx index 801469ea..1291801e 100644 --- a/packages/particle-editor/src/providers/ParticleInspectorProvider.tsx +++ b/packages/particle-editor/src/providers/ParticleInspectorProvider.tsx @@ -1,13 +1,15 @@ /** * 粒子系统 Inspector Provider * Particle System Inspector Provider + * + * 检视器显示控制按钮,资产选择通过 @Property 装饰器自动渲染 + * Inspector shows control buttons, asset selection is auto-rendered via @Property decorator */ -import React, { useState, useCallback } from 'react'; -import { Play, Pause, RotateCcw, Sparkles } from 'lucide-react'; +import React, { useState, useCallback, useEffect } from 'react'; +import { Play, Pause, RotateCcw, Sparkles, Loader2 } from 'lucide-react'; import type { IInspectorProvider, InspectorContext } from '@esengine/editor-core'; import type { ParticleSystemComponent } from '@esengine/particle'; -import { EmissionShape, ParticleBlendMode } from '@esengine/particle'; interface ParticleInspectorData { entityId: string; @@ -41,37 +43,65 @@ interface ParticleInspectorUIProps { function ParticleInspectorUI({ data }: ParticleInspectorUIProps) { const { component } = data; const [, forceUpdate] = useState({}); + const [isLoading, setIsLoading] = useState(false); + const [lastGuid, setLastGuid] = useState(component.particleAssetGuid); const refresh = useCallback(() => forceUpdate({}), []); - const handlePlay = () => { + // 当资产 GUID 变化时自动加载资产 + // Auto-load asset when GUID changes + useEffect(() => { + const currentGuid = component.particleAssetGuid; + if (currentGuid !== lastGuid) { + setLastGuid(currentGuid); + if (currentGuid) { + setIsLoading(true); + component.loadAsset(currentGuid).finally(() => { + setIsLoading(false); + refresh(); + }); + } else { + // 清除已加载的资产 + component.setAssetData(null); + refresh(); + } + } + }, [component, component.particleAssetGuid, lastGuid, refresh]); + + const handlePlay = useCallback(async () => { + // 如果有资产 GUID 但尚未加载,先加载 + if (component.particleAssetGuid && !component.loadedAsset) { + setIsLoading(true); + await component.loadAsset(component.particleAssetGuid); + setIsLoading(false); + } component.play(); refresh(); - }; + }, [component, refresh]); - const handlePause = () => { + const handlePause = useCallback(() => { component.pause(); refresh(); - }; + }, [component, refresh]); - const handleStop = () => { + const handleStop = useCallback(() => { component.stop(true); refresh(); - }; + }, [component, refresh]); - const handleBurst = () => { - component.burst(10); + const handleBurst = useCallback(async () => { + // 如果有资产 GUID 但尚未加载,先加载 + if (component.particleAssetGuid && !component.loadedAsset) { + setIsLoading(true); + await component.loadAsset(component.particleAssetGuid); + setIsLoading(false); + } + component.emit(10); refresh(); - }; + }, [component, refresh]); - const handleChange = ( - key: K, - value: ParticleSystemComponent[K] - ) => { - (component as any)[key] = value; - component.markDirty(); - refresh(); - }; + const hasAsset = !!component.particleAssetGuid; + const isAssetLoaded = !!component.loadedAsset; return (
@@ -83,247 +113,50 @@ function ParticleInspectorUI({ data }: ParticleInspectorUIProps) { onClick={component.isPlaying ? handlePause : handlePlay} style={buttonStyle} title={component.isPlaying ? 'Pause' : 'Play'} + disabled={isLoading} > - {component.isPlaying ? : } + {isLoading ? : + component.isPlaying ? : } - -
- {component.activeParticleCount} / {component.maxParticles} + {component.activeParticleCount} / {component.loadedAsset?.maxParticles ?? component.maxParticles}
-
- - {/* 基础属性 | Basic Properties */} -
-
Basic
- - handleChange('maxParticles', v)} - /> - - handleChange('looping', v)} - /> - - handleChange('duration', v)} - /> - - handleChange('playbackSpeed', v)} - /> -
- - {/* 发射属性 | Emission Properties */} -
-
Emission
- - handleChange('emissionRate', v)} - /> - - handleChange('emissionShape', v as EmissionShape)} - /> - - {component.emissionShape !== EmissionShape.Point && ( - handleChange('shapeRadius', v)} - /> + {hasAsset && ( +
+ + + {isLoading ? 'Loading...' : isAssetLoaded ? 'Loaded' : 'Not loaded'} + +
)}
- {/* 粒子属性 | Particle Properties */} -
-
Particle
- - handleChange('lifetimeMin', v)} - onMaxChange={v => handleChange('lifetimeMax', v)} - /> - - handleChange('speedMin', v)} - onMaxChange={v => handleChange('speedMax', v)} - /> - - handleChange('direction', v)} - /> - - handleChange('directionSpread', v)} - /> - - handleChange('scaleMin', v)} - onMaxChange={v => handleChange('scaleMax', v)} - /> - - handleChange('gravityX', v)} - /> - - handleChange('gravityY', v)} - /> -
- - {/* 颜色属性 | Color Properties */} -
-
Color
- - handleChange('startColor', v)} - /> - - handleChange('startAlpha', v)} - /> - - handleChange('endAlpha', v)} - /> - - handleChange('endScale', v)} - /> -
- - {/* 渲染属性 | Rendering Properties */} -
-
Rendering
- - handleChange('particleSize', v)} - /> - - handleChange('blendMode', v as ParticleBlendMode)} - /> - - handleChange('sortingOrder', v)} - /> -
+ {/* 提示信息 | Hint */} + {!hasAsset && ( +
+ No particle asset selected. Drag a .particle file to the Particle Asset field above, or create one in Content Browser. +
+ )}
); } -// ============= UI Components ============= - -const inputStyle: React.CSSProperties = { - width: '100%', - padding: '4px 8px', - border: '1px solid var(--border-color, #3a3a3a)', - borderRadius: '4px', - background: 'var(--input-background, #2a2a2a)', - color: 'var(--text-color, #e0e0e0)', - fontSize: '12px', -}; - const buttonStyle: React.CSSProperties = { display: 'flex', alignItems: 'center', @@ -336,133 +169,3 @@ const buttonStyle: React.CSSProperties = { cursor: 'pointer', fontSize: '12px', }; - -interface NumberInputProps { - label: string; - value: number; - min?: number; - max?: number; - step?: number; - onChange: (value: number) => void; -} - -function NumberInput({ label, value, min, max, step = 1, onChange }: NumberInputProps) { - return ( -
- - onChange(parseFloat(e.target.value) || 0)} - style={inputStyle} - /> -
- ); -} - -interface RangeInputProps { - label: string; - minValue: number; - maxValue: number; - min?: number; - max?: number; - step?: number; - onMinChange: (value: number) => void; - onMaxChange: (value: number) => void; -} - -function RangeInput({ label, minValue, maxValue, min, max, step = 1, onMinChange, onMaxChange }: RangeInputProps) { - return ( -
- -
- onMinChange(parseFloat(e.target.value) || 0)} - style={{ ...inputStyle, width: '50%' }} - title="Min" - /> - onMaxChange(parseFloat(e.target.value) || 0)} - style={{ ...inputStyle, width: '50%' }} - title="Max" - /> -
-
- ); -} - -interface CheckboxInputProps { - label: string; - checked: boolean; - onChange: (value: boolean) => void; -} - -function CheckboxInput({ label, checked, onChange }: CheckboxInputProps) { - return ( -
- - onChange(e.target.checked)} - /> -
- ); -} - -interface SelectInputProps { - label: string; - value: string; - options: { value: string; label: string }[]; - onChange: (value: string) => void; -} - -function SelectInput({ label, value, options, onChange }: SelectInputProps) { - return ( -
- - -
- ); -} - -interface ColorInputProps { - label: string; - value: string; - onChange: (value: string) => void; -} - -function ColorInput({ label, value, onChange }: ColorInputProps) { - return ( -
- - onChange(e.target.value)} - style={{ ...inputStyle, padding: '2px', height: '24px' }} - /> -
- ); -} diff --git a/packages/particle-editor/src/stores/ParticleEditorStore.ts b/packages/particle-editor/src/stores/ParticleEditorStore.ts index 9bdb8416..3d13d2c8 100644 --- a/packages/particle-editor/src/stores/ParticleEditorStore.ts +++ b/packages/particle-editor/src/stores/ParticleEditorStore.ts @@ -78,10 +78,14 @@ export const useParticleEditorStore = create((set) => ({ setPendingFilePath: (path) => set({ pendingFilePath: path }), - setParticleData: (data) => set({ + setParticleData: (data) => set((state) => ({ particleData: data, - isDirty: false, - }), + // 如果有文件路径,修改数据时应该标记为 dirty + // 如果没有文件路径且之前也没有数据,则是加载文件,不标记 dirty + // If has file path, mark as dirty when data changes + // If no file path and no previous data, it's loading, don't mark dirty + isDirty: state.filePath !== null || state.particleData !== null, + })), updateProperty: (key, value) => set((state) => { if (!state.particleData) return state; @@ -115,7 +119,7 @@ export const useParticleEditorStore = create((set) => ({ particleData: createDefaultParticleAsset(name), filePath: null, isDirty: true, - isPlaying: false, + isPlaying: true, // 自动播放 | Auto play selectedPreset: null, }), })); diff --git a/packages/particle/package.json b/packages/particle/package.json index cb2403da..5ef8b53c 100644 --- a/packages/particle/package.json +++ b/packages/particle/package.json @@ -27,8 +27,10 @@ "type-check": "tsc --noEmit", "clean": "rimraf dist" }, + "dependencies": { + "@esengine/asset-system": "workspace:*" + }, "devDependencies": { - "@esengine/asset-system": "workspace:*", "@esengine/ecs-framework": "workspace:*", "@esengine/ecs-framework-math": "workspace:*", "@esengine/engine-core": "workspace:*", diff --git a/packages/particle/src/ParticleEmitter.ts b/packages/particle/src/ParticleEmitter.ts index f5d01876..58281f56 100644 --- a/packages/particle/src/ParticleEmitter.ts +++ b/packages/particle/src/ParticleEmitter.ts @@ -159,9 +159,20 @@ export class ParticleEmitter { * @param dt - Delta time in seconds * @param worldX - World position X * @param worldY - World position Y + * @param worldRotation - World rotation in radians (applied to emission direction) + * @param worldScaleX - World scale X (applied to emission offset and speed) + * @param worldScaleY - World scale Y (applied to emission offset and speed) * @returns Number of particles emitted */ - emit(pool: ParticlePool, dt: number, worldX: number, worldY: number): number { + emit( + pool: ParticlePool, + dt: number, + worldX: number, + worldY: number, + worldRotation: number = 0, + worldScaleX: number = 1, + worldScaleY: number = 1 + ): number { if (!this._isEmitting) return 0; let emitted = 0; @@ -171,7 +182,7 @@ export class ParticleEmitter { for (let i = 0; i < this.config.burstCount; i++) { const p = pool.spawn(); if (p) { - this._initParticle(p, worldX, worldY); + this._initParticle(p, worldX, worldY, worldRotation, worldScaleX, worldScaleY); emitted++; } } @@ -182,7 +193,7 @@ export class ParticleEmitter { while (this._emissionAccumulator >= 1) { const p = pool.spawn(); if (p) { - this._initParticle(p, worldX, worldY); + this._initParticle(p, worldX, worldY, worldRotation, worldScaleX, worldScaleY); emitted++; } this._emissionAccumulator -= 1; @@ -195,13 +206,29 @@ export class ParticleEmitter { /** * 立即爆发发射 * Burst emit immediately + * + * @param pool - Particle pool + * @param count - Number of particles to emit + * @param worldX - World position X + * @param worldY - World position Y + * @param worldRotation - World rotation in radians + * @param worldScaleX - World scale X + * @param worldScaleY - World scale Y */ - burst(pool: ParticlePool, count: number, worldX: number, worldY: number): number { + burst( + pool: ParticlePool, + count: number, + worldX: number, + worldY: number, + worldRotation: number = 0, + worldScaleX: number = 1, + worldScaleY: number = 1 + ): number { let emitted = 0; for (let i = 0; i < count; i++) { const p = pool.spawn(); if (p) { - this._initParticle(p, worldX, worldY); + this._initParticle(p, worldX, worldY, worldRotation, worldScaleX, worldScaleY); emitted++; } } @@ -217,23 +244,45 @@ export class ParticleEmitter { this._isEmitting = true; } - private _initParticle(p: Particle, worldX: number, worldY: number): void { + private _initParticle( + p: Particle, + worldX: number, + worldY: number, + worldRotation: number = 0, + worldScaleX: number = 1, + worldScaleY: number = 1 + ): void { const config = this.config; - // 位置 | Position + // 获取形状偏移 | Get shape offset const [ox, oy] = this._getShapeOffset(); - p.x = worldX + ox; - p.y = worldY + oy; + + // 应用旋转和缩放到发射偏移 | Apply rotation and scale to emission offset + // 先缩放,再旋转 | Scale first, then rotate + const scaledOx = ox * worldScaleX; + const scaledOy = oy * worldScaleY; + const cos = Math.cos(worldRotation); + const sin = Math.sin(worldRotation); + const rotatedOx = scaledOx * cos - scaledOy * sin; + const rotatedOy = scaledOx * sin + scaledOy * cos; + + // 位置 | Position + p.x = worldX + rotatedOx; + p.y = worldY + rotatedOy; // 生命时间 | Lifetime p.lifetime = randomRange(config.lifetime.min, config.lifetime.max); p.age = 0; - // 速度方向 | Velocity direction - const dir = config.direction + randomRange(-config.directionSpread / 2, config.directionSpread / 2); + // 速度方向(应用世界旋转)| Velocity direction (apply world rotation) + const baseDir = config.direction + randomRange(-config.directionSpread / 2, config.directionSpread / 2); + const dir = baseDir + worldRotation; const speed = randomRange(config.speed.min, config.speed.max); - p.vx = Math.cos(dir) * speed; - p.vy = Math.sin(dir) * speed; + + // 速度也应用缩放(使用平均缩放)| Speed also applies scale (use average scale) + const avgScale = (worldScaleX + worldScaleY) / 2; + p.vx = Math.cos(dir) * speed * avgScale; + p.vy = Math.sin(dir) * speed * avgScale; // 加速度(重力)| Acceleration (gravity) p.ax = config.gravityX; @@ -243,12 +292,12 @@ export class ParticleEmitter { p.rotation = randomRange(config.startRotation.min, config.startRotation.max); p.angularVelocity = randomRange(config.angularVelocity.min, config.angularVelocity.max); - // 缩放 | Scale - const scale = randomRange(config.startScale.min, config.startScale.max); - p.scaleX = scale; - p.scaleY = scale; - p.startScaleX = scale; - p.startScaleY = scale; + // 缩放(应用世界缩放)| Scale (apply world scale) + const baseScale = randomRange(config.startScale.min, config.startScale.max); + p.scaleX = baseScale * worldScaleX; + p.scaleY = baseScale * worldScaleY; + p.startScaleX = p.scaleX; + p.startScaleY = p.scaleY; // 颜色 | Color p.r = clamp(config.startColor.r + randomRange(-config.startColorVariance.r, config.startColorVariance.r), 0, 1); diff --git a/packages/particle/src/ParticleRuntimeModule.ts b/packages/particle/src/ParticleRuntimeModule.ts index 1fe96ea5..e5c90047 100644 --- a/packages/particle/src/ParticleRuntimeModule.ts +++ b/packages/particle/src/ParticleRuntimeModule.ts @@ -1,10 +1,28 @@ import type { ComponentRegistry as ComponentRegistryType, IScene } from '@esengine/ecs-framework'; import type { IRuntimeModule, IPlugin, ModuleManifest, SystemContext } from '@esengine/engine-core'; +import { assetManager as globalAssetManager, type AssetManager } from '@esengine/asset-system'; import { ParticleSystemComponent } from './ParticleSystemComponent'; import { ParticleUpdateSystem } from './systems/ParticleSystem'; +import { ParticleLoader, ParticleAssetType } from './loaders/ParticleLoader'; export type { SystemContext, ModuleManifest, IRuntimeModule as IRuntimeModuleLoader, IPlugin as IPluginLoader }; +/** + * 引擎桥接接口(用于直接加载纹理) + * Engine bridge interface (for direct texture loading) + */ +export interface IEngineBridge { + loadTexture(id: number, url: string): Promise; +} + +/** + * 引擎集成接口(用于加载纹理) + * Engine integration interface (for loading textures) + */ +export interface IEngineIntegration { + loadTextureForComponent(texturePath: string): Promise; +} + /** * 粒子系统上下文 * Particle system context @@ -18,10 +36,17 @@ export interface ParticleSystemContext extends SystemContext { addRenderDataProvider(provider: any): void; removeRenderDataProvider(provider: any): void; }; + /** 引擎集成(用于加载纹理)| Engine integration (for loading textures) */ + engineIntegration?: IEngineIntegration; + /** 引擎桥接(用于直接加载纹理)| Engine bridge (for direct texture loading) */ + engineBridge?: IEngineBridge; + /** 资产管理器(用于注册加载器)| Asset manager (for registering loaders) */ + assetManager?: AssetManager; } class ParticleRuntimeModule implements IRuntimeModule { private _updateSystem: ParticleUpdateSystem | null = null; + private _loaderRegistered = false; registerComponents(registry: typeof ComponentRegistryType): void { registry.register(ParticleSystemComponent); @@ -30,6 +55,24 @@ class ParticleRuntimeModule implements IRuntimeModule { createSystems(scene: IScene, context: SystemContext): void { const particleContext = context as ParticleSystemContext; + // 注册粒子资产加载器到上下文的 assetManager 和全局单例 + // Register particle asset loader to context assetManager AND global singleton + if (!this._loaderRegistered) { + const loader = new ParticleLoader(); + + // Register to context's assetManager (used by GameRuntime) + if (particleContext.assetManager) { + particleContext.assetManager.registerLoader(ParticleAssetType as any, loader); + } + + // Also register to global singleton (used by ParticleSystemComponent.loadAsset) + // 同时注册到全局单例(ParticleSystemComponent.loadAsset 使用的是全局单例) + globalAssetManager.registerLoader(ParticleAssetType as any, loader); + + this._loaderRegistered = true; + console.log('[ParticleRuntimeModule] Registered ParticleLoader to both context and global assetManager'); + } + this._updateSystem = new ParticleUpdateSystem(); // 设置 Transform 组件类型 | Set Transform component type @@ -37,9 +80,14 @@ class ParticleRuntimeModule implements IRuntimeModule { this._updateSystem.setTransformType(particleContext.transformType); } - // 在编辑器中禁用系统(手动控制)| Disable in editor (manual control) - if (context.isEditor) { - this._updateSystem.enabled = false; + // 设置引擎集成(用于加载纹理)| Set engine integration (for loading textures) + if (particleContext.engineIntegration) { + this._updateSystem.setEngineIntegration(particleContext.engineIntegration); + } + + // 设置引擎桥接(用于加载默认纹理)| Set engine bridge (for loading default texture) + if (particleContext.engineBridge) { + this._updateSystem.setEngineBridge(particleContext.engineBridge); } scene.addSystem(this._updateSystem); @@ -70,7 +118,7 @@ const manifest: ModuleManifest = { category: 'Rendering', icon: 'Sparkles', isCore: false, - defaultEnabled: false, + defaultEnabled: true, isEngineModule: true, canContainContent: true, dependencies: ['core', 'math', 'sprite'], diff --git a/packages/particle/src/ParticleSystemComponent.ts b/packages/particle/src/ParticleSystemComponent.ts index c4fb836f..e05ae81d 100644 --- a/packages/particle/src/ParticleSystemComponent.ts +++ b/packages/particle/src/ParticleSystemComponent.ts @@ -1,26 +1,19 @@ import { Component, ECSComponent, Property, Serializable, Serialize } from '@esengine/ecs-framework'; -import { ParticlePool } from './Particle'; -import { ParticleEmitter, EmissionShape, createDefaultEmitterConfig, type EmitterConfig, type ValueRange, type ColorValue } from './ParticleEmitter'; +import { assetManager } from '@esengine/asset-system'; +import { ParticlePool, type Particle } from './Particle'; +import { ParticleEmitter, EmissionShape, createDefaultEmitterConfig, type EmitterConfig, type ColorValue } from './ParticleEmitter'; import type { IParticleModule } from './modules/IParticleModule'; import { ColorOverLifetimeModule } from './modules/ColorOverLifetimeModule'; import { SizeOverLifetimeModule } from './modules/SizeOverLifetimeModule'; import { CollisionModule } from './modules/CollisionModule'; import { ForceFieldModule } from './modules/ForceFieldModule'; +import type { IParticleAsset, IBurstConfig } from './loaders/ParticleLoader'; -/** - * 爆发配置 - * Burst configuration - */ -export interface BurstConfig { - /** 触发时间(秒)| Trigger time (seconds) */ - time: number; - /** 发射数量 | Particle count */ - count: number; - /** 循环次数(0=无限)| Number of cycles (0=infinite) */ - cycles: number; - /** 循环间隔(秒)| Interval between cycles (seconds) */ - interval: number; -} +// Re-export for backward compatibility +// 为了向后兼容重新导出 +export type { IBurstConfig }; +/** @deprecated Use IBurstConfig instead */ +export type BurstConfig = IBurstConfig; /** * 粒子混合模式 @@ -46,15 +39,62 @@ export enum SimulationSpace { World = 'world' } +/** + * 运行时覆盖配置 + * Runtime override configuration + * + * 用于在游戏运行时动态修改粒子系统参数,而不影响原始资产配置。 + * Used to dynamically modify particle system parameters at runtime without affecting the original asset configuration. + */ +export interface ParticleRuntimeOverrides { + /** 发射速率覆盖 | Emission rate override */ + emissionRate?: number; + /** 播放速度覆盖 | Playback speed override */ + playbackSpeed?: number; + /** 是否循环覆盖 | Looping override */ + looping?: boolean; + /** 重力X覆盖 | Gravity X override */ + gravityX?: number; + /** 重力Y覆盖 | Gravity Y override */ + gravityY?: number; + /** 起始颜色覆盖 | Start color override */ + startColor?: ColorValue; + /** 缩放乘数(应用于所有尺寸)| Scale multiplier (applied to all sizes) */ + scaleMultiplier?: number; + /** 速度乘数 | Speed multiplier */ + speedMultiplier?: number; +} + /** * 粒子系统组件 * Particle system component * - * Manages particle emission, simulation, and provides data for rendering. - * 管理粒子发射、模拟,并为渲染提供数据。 + * 基于资产的粒子系统组件。所有粒子配置从 .particle 文件读取, + * 运行时可通过 runtimeOverrides 动态修改部分参数。 + * + * Asset-based particle system component. All particle configuration is read from .particle files. + * Runtime modifications can be made through runtimeOverrides. + * + * @example + * ```typescript + * // 在编辑器中设置 particleAssetGuid,运行时自动加载 + * // Set particleAssetGuid in editor, loads automatically at runtime + * + * // 运行时修改发射速率 + * // Modify emission rate at runtime + * particle.setOverride('emissionRate', 50); + * + * // 或批量设置 + * // Or set multiple overrides + * particle.setOverrides({ emissionRate: 50, playbackSpeed: 2 }); + * + * // 清除覆盖,恢复原始值 + * // Clear overrides, restore original values + * particle.clearOverrides(); + * ``` */ @ECSComponent('ParticleSystem') -@Serializable({ version: 2, typeId: 'ParticleSystem' }) +@Serializable({ version: 3, typeId: 'ParticleSystem' }) export class ParticleSystemComponent extends Component { // ============= 资产引用 | Asset Reference ============= @@ -62,192 +102,57 @@ export class ParticleSystemComponent extends Component { * 粒子效果资产 GUID * Particle effect asset GUID * - * When set, loads particle configuration from .particle file. - * Inline properties below are ignored when asset is set. - * 设置后从 .particle 文件加载粒子配置。 - * 设置了资产后,下面的内联属性将被忽略。 + * 必须设置此属性才能使用粒子系统。所有配置从 .particle 文件读取。 + * Must be set to use the particle system. All configuration is read from .particle file. */ @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() - @Property({ type: 'integer', label: 'Max Particles', min: 1, max: 10000 }) - public maxParticles: number = 1000; - - /** 是否循环播放 | Whether to loop */ - @Serialize() - @Property({ type: 'boolean', label: 'Looping' }) - public looping: boolean = true; - - /** 预热时间(秒)| Prewarm time (seconds) */ - @Serialize() - @Property({ type: 'number', label: 'Prewarm Time', min: 0 }) - public prewarmTime: number = 0; - - /** 持续时间(秒,非循环时使用)| Duration (seconds, for non-looping) */ - @Serialize() - @Property({ type: 'number', label: 'Duration', min: 0.1 }) - public duration: number = 5; - - /** 播放速度倍率 | Playback speed multiplier */ - @Serialize() - @Property({ type: 'number', label: 'Playback Speed', min: 0.01, max: 10 }) - public playbackSpeed: number = 1; - - /** 模拟空间 | Simulation space */ - @Serialize() - @Property({ type: 'enum', label: 'Simulation Space', options: [ - { value: 'world', label: 'World' }, - { value: 'local', label: 'Local' } - ]}) - public simulationSpace: SimulationSpace = SimulationSpace.World; - - // ============= 发射器属性 | Emitter Properties ============= - - /** 每秒发射数量 | Emission rate (particles per second) */ - @Serialize() - @Property({ type: 'number', label: 'Emission Rate', min: 0 }) - public emissionRate: number = 10; - - /** 发射形状 | Emission shape */ - @Serialize() - @Property({ type: 'enum', label: 'Shape', options: [ - { value: 'point', label: 'Point' }, - { value: 'circle', label: 'Circle' }, - { value: 'rectangle', label: 'Rectangle' }, - { value: 'line', label: 'Line' }, - { value: 'cone', label: 'Cone' } - ]}) - public emissionShape: EmissionShape = EmissionShape.Point; - - /** 形状半径 | Shape radius */ - @Serialize() - @Property({ type: 'number', label: 'Shape Radius', min: 0 }) - public shapeRadius: number = 0; - - /** 形状宽度 | Shape width */ - @Serialize() - @Property({ type: 'number', label: 'Shape Width', min: 0 }) - public shapeWidth: number = 0; - - /** 形状高度 | Shape height */ - @Serialize() - @Property({ type: 'number', label: 'Shape Height', min: 0 }) - public shapeHeight: number = 0; - - // ============= 粒子属性 | Particle Properties ============= - - /** 粒子生命时间最小值(秒)| Particle lifetime min (seconds) */ - @Serialize() - @Property({ type: 'number', label: 'Lifetime Min', min: 0.01 }) - public lifetimeMin: number = 1; - - /** 粒子生命时间最大值(秒)| Particle lifetime max (seconds) */ - @Serialize() - @Property({ type: 'number', label: 'Lifetime Max', min: 0.01 }) - public lifetimeMax: number = 2; - - /** 初始速度最小值 | Initial speed min */ - @Serialize() - @Property({ type: 'number', label: 'Speed Min', min: 0 }) - public speedMin: number = 50; - - /** 初始速度最大值 | Initial speed max */ - @Serialize() - @Property({ type: 'number', label: 'Speed Max', min: 0 }) - public speedMax: number = 100; - - /** 发射方向(角度)| Emission direction (degrees) */ - @Serialize() - @Property({ type: 'number', label: 'Direction', min: -180, max: 180 }) - public direction: number = -90; - - /** 发射方向扩散(角度)| Direction spread (degrees) */ - @Serialize() - @Property({ type: 'number', label: 'Direction Spread', min: 0, max: 360 }) - public directionSpread: number = 0; - - /** 初始缩放最小值 | Initial scale min */ - @Serialize() - @Property({ type: 'number', label: 'Scale Min', min: 0.01 }) - public scaleMin: number = 1; - - /** 初始缩放最大值 | Initial scale max */ - @Serialize() - @Property({ type: 'number', label: 'Scale Max', min: 0.01 }) - public scaleMax: number = 1; - - /** 重力X | Gravity X */ - @Serialize() - @Property({ type: 'number', label: 'Gravity X' }) - public gravityX: number = 0; - - /** 重力Y | Gravity Y */ - @Serialize() - @Property({ type: 'number', label: 'Gravity Y' }) - public gravityY: number = 0; - - // ============= 颜色属性 | Color Properties ============= - - /** 起始颜色 | Start color */ - @Serialize() - @Property({ type: 'color', label: 'Start Color' }) - public startColor: string = '#ffffff'; - - /** 起始透明度 | Start alpha */ - @Serialize() - @Property({ type: 'number', label: 'Start Alpha', min: 0, max: 1, step: 0.01 }) - public startAlpha: number = 1; - - /** 结束透明度(淡出)| End alpha (fade out) */ - @Serialize() - @Property({ type: 'number', label: 'End Alpha', min: 0, max: 1, step: 0.01 }) - public endAlpha: number = 0; - - /** 结束缩放乘数 | End scale multiplier */ - @Serialize() - @Property({ type: 'number', label: 'End Scale', min: 0 }) - public endScale: number = 0; - - // ============= 渲染属性 | Rendering Properties ============= + // ============= 播放控制 | Playback Control ============= /** - * 粒子纹理 GUID - * Particle texture GUID + * 是否自动播放 + * Whether to auto-play on start + * + * 默认为 false,在编辑器中需要手动点击播放按钮。 + * 运行时场景中如需自动播放,请设置为 true。 + * + * Default is false, manual play button click is required in editor. + * Set to true for auto-play in runtime scenes. */ @Serialize() - @Property({ type: 'asset', label: 'Texture', assetType: 'texture' }) - public textureGuid: string = ''; + @Property({ type: 'boolean', label: 'Auto Play' }) + public autoPlay: boolean = false; - /** 粒子尺寸(像素)| Particle size (pixels) */ + /** + * 模拟空间 + * Simulation space + * + * Local: 粒子跟随发射器移动 + * World: 粒子在世界空间独立运动 + */ @Serialize() - @Property({ type: 'number', label: 'Particle Size', min: 1 }) - public particleSize: number = 8; + @Property({ + type: 'enum', + label: 'Simulation Space', + options: [ + { label: 'Local', value: SimulationSpace.Local }, + { label: 'World', value: SimulationSpace.World } + ] + }) + public simulationSpace: SimulationSpace = SimulationSpace.World; - /** 混合模式 | Blend mode */ - @Serialize() - @Property({ type: 'enum', label: 'Blend Mode', options: [ - { value: 'normal', label: 'Normal' }, - { value: 'additive', label: 'Additive' }, - { value: 'multiply', label: 'Multiply' } - ]}) - public blendMode: ParticleBlendMode = ParticleBlendMode.Additive; + // ============= 运行时覆盖 | Runtime Overrides ============= - /** 排序顺序 | Sorting order */ - @Serialize() - @Property({ type: 'integer', label: 'Sorting Order' }) - public sortingOrder: number = 0; - - // ============= 爆发配置 | Burst Configuration ============= - - /** 爆发列表 | Burst list */ - @Serialize() - public bursts: BurstConfig[] = []; + /** + * 运行时参数覆盖 + * Runtime parameter overrides + * + * 这些值会覆盖资产中的对应配置。不会被序列化保存。 + * These values override corresponding asset configuration. Not serialized. + */ + private _runtimeOverrides: ParticleRuntimeOverrides = {}; // ============= 运行时状态 | Runtime State ============= @@ -262,10 +167,22 @@ export class ParticleSystemComponent extends Component { /** 上一帧发射器位置(本地空间用)| Last frame emitter position (for local space) */ private _lastEmitterX: number = 0; private _lastEmitterY: number = 0; + /** 当前世界旋转(弧度)| Current world rotation (radians) */ + private _worldRotation: number = 0; + /** 当前世界缩放X | Current world scale X */ + private _worldScaleX: number = 1; + /** 当前世界缩放Y | Current world scale Y */ + private _worldScaleY: number = 1; + /** 已加载的粒子资产数据 | Loaded particle asset data */ + private _loadedAsset: IParticleAsset | null = null; + /** 上次加载的资产 GUID(用于检测变化)| Last loaded asset GUID (for change detection) */ + private _lastLoadedGuid: string = ''; /** 纹理ID(运行时)| Texture ID (runtime) */ public textureId: number = 0; + // ============= 公开属性访问器 | Public Property Accessors ============= + /** 是否正在播放 | Whether playing */ get isPlaying(): boolean { return this._isPlaying; @@ -291,74 +208,280 @@ export class ParticleSystemComponent extends Component { return this._modules; } + /** 已加载的资产数据 | Loaded asset data */ + get loadedAsset(): IParticleAsset | null { + return this._loadedAsset; + } + + /** + * 获取当前运行时覆盖配置 + * Get current runtime overrides + */ + get runtimeOverrides(): Readonly { + return this._runtimeOverrides; + } + + /** 当前世界旋转(弧度)| Current world rotation (radians) */ + get worldRotation(): number { + return this._worldRotation; + } + + /** 当前世界缩放X | Current world scale X */ + get worldScaleX(): number { + return this._worldScaleX; + } + + /** 当前世界缩放Y | Current world scale Y */ + get worldScaleY(): number { + return this._worldScaleY; + } + + // ============= 从资产或覆盖读取的属性 | Properties from Asset or Overrides ============= + + /** 最大粒子数(从资产读取)| Maximum particles (from asset) */ + get maxParticles(): number { + return this._loadedAsset?.maxParticles ?? 1000; + } + + /** 是否循环 | Whether looping */ + get looping(): boolean { + return this._runtimeOverrides.looping ?? this._loadedAsset?.looping ?? true; + } + + /** 持续时间(秒)| Duration in seconds */ + get duration(): number { + return this._loadedAsset?.duration ?? 5; + } + + /** 播放速度 | Playback speed */ + get playbackSpeed(): number { + return this._runtimeOverrides.playbackSpeed ?? this._loadedAsset?.playbackSpeed ?? 1; + } + + /** 发射速率 | Emission rate */ + get emissionRate(): number { + return this._runtimeOverrides.emissionRate ?? this._loadedAsset?.emissionRate ?? 10; + } + + /** 混合模式(从资产读取)| Blend mode (from asset) */ + get blendMode(): ParticleBlendMode { + return this._loadedAsset?.blendMode ?? ParticleBlendMode.Additive; + } + + /** 粒子尺寸(从资产读取)| Particle size (from asset) */ + get particleSize(): number { + return this._loadedAsset?.particleSize ?? 8; + } + + /** 纹理 GUID(从资产读取)| Texture GUID (from asset) */ + get textureGuid(): string { + return this._loadedAsset?.textureGuid ?? ''; + } + + /** 排序顺序(从资产读取)| Sorting order (from asset) */ + get sortingOrder(): number { + return this._loadedAsset?.sortingOrder ?? 0; + } + + /** 爆发列表(从资产读取)| Burst list (from asset) */ + get bursts(): IBurstConfig[] { + return this._loadedAsset?.bursts ?? []; + } + + // ============= 运行时覆盖方法 | Runtime Override Methods ============= + + /** + * 设置单个运行时覆盖参数 + * Set a single runtime override parameter + * + * @param key 参数名 | Parameter name + * @param value 参数值 | Parameter value + */ + setOverride(key: K, value: ParticleRuntimeOverrides[K]): void { + this._runtimeOverrides[key] = value; + this._needsRebuild = true; + } + + /** + * 批量设置运行时覆盖参数 + * Set multiple runtime override parameters + * + * @param overrides 覆盖配置 | Override configuration + */ + setOverrides(overrides: Partial): void { + Object.assign(this._runtimeOverrides, overrides); + this._needsRebuild = true; + } + + /** + * 清除所有运行时覆盖,恢复资产原始值 + * Clear all runtime overrides, restore asset original values + */ + clearOverrides(): void { + this._runtimeOverrides = {}; + this._needsRebuild = true; + } + + /** + * 清除指定的运行时覆盖参数 + * Clear specific runtime override parameter + * + * @param key 参数名 | Parameter name + */ + clearOverride(key: K): void { + delete this._runtimeOverrides[key]; + this._needsRebuild = true; + } + + // ============= 生命周期方法 | Lifecycle Methods ============= + /** * 初始化粒子系统 * Initialize particle system */ initialize(): void { this._rebuildIfNeeded(); + + // 自动播放 | Auto play + if (this.autoPlay && !this._isPlaying) { + this.play(); + } } + /** + * 加载粒子资产 + * Load particle asset + * + * @param guid - Asset GUID to load | 要加载的资产 GUID + * @returns Promise that resolves when asset is loaded | 资产加载完成时解析的 Promise + */ + async loadAsset(guid: string, bForceReload: boolean = false): Promise { + if (!guid) { + this._loadedAsset = null; + this._lastLoadedGuid = ''; + this._needsRebuild = true; + return true; + } + + // 如果是同一个资产且不强制重新加载,不需要重新加载 + // If same asset and not force reload, no need to reload + if (guid === this._lastLoadedGuid && this._loadedAsset && !bForceReload) { + return true; + } + + try { + console.log(`[ParticleSystem] Loading asset: ${guid}${bForceReload ? ' (force reload)' : ''}`); + const result = await assetManager.loadAsset(guid, { forceReload: bForceReload }); + const asset = result?.asset; + + if (asset) { + this._loadedAsset = asset; + this._lastLoadedGuid = guid; + this._needsRebuild = true; + console.log(`[ParticleSystem] Asset loaded successfully:`, asset.name); + return true; + } else { + console.warn(`[ParticleSystem] Failed to load asset: ${guid}`); + return false; + } + } catch (error) { + console.error(`[ParticleSystem] Error loading asset ${guid}:`, error); + return false; + } + } + + /** + * 强制重新加载资产 + * Force reload the asset + * + * 当资产文件内容变化时调用此方法,强制从文件系统重新加载。 + * Call this method when asset file content changes, forcing a reload from filesystem. + */ + async reloadAsset(): Promise { + if (!this.particleAssetGuid) return false; + return this.loadAsset(this.particleAssetGuid, true); + } + + /** + * 设置资产数据(由加载器调用) + * Set asset data (called by loader) + * + * @param asset 粒子资产数据 | Particle asset data + */ + setAssetData(asset: IParticleAsset | null): void { + this._loadedAsset = asset; + this._needsRebuild = true; + } + + // ============= 播放控制 | Playback Control ============= + /** * 播放粒子系统 - * Play particle system - * - * @param worldX - Initial world position X for prewarm | 预热时的初始世界坐标X - * @param worldY - Initial world position Y for prewarm | 预热时的初始世界坐标Y + * Play the particle system */ - play(worldX: number = 0, worldY: number = 0): void { + play(): void { this._rebuildIfNeeded(); - this._isPlaying = true; - this._emitter!.isEmitting = true; - this._elapsedTime = 0; - // 初始化爆发状态 | Initialize burst states - this._burstStates = this.bursts.map(() => ({ firedCount: 0, lastFireTime: -Infinity })); - // 初始化发射器位置 | Initialize emitter position - this._lastEmitterX = worldX; - this._lastEmitterY = worldY; - if (this.prewarmTime > 0) { - this._simulate(this.prewarmTime, worldX, worldY); + if (this._emitter) { + this._emitter.isEmitting = true; } - } - - /** - * 停止粒子系统 - * Stop particle system - */ - stop(clearParticles: boolean = false): void { - this._isPlaying = false; - this._emitter!.isEmitting = false; + this._isPlaying = true; this._elapsedTime = 0; + // 重置爆发状态 | Reset burst states this._burstStates = this.bursts.map(() => ({ firedCount: 0, lastFireTime: -Infinity })); - - if (clearParticles) { - this._pool?.recycleAll(); - } } /** * 暂停粒子系统 - * Pause particle system + * Pause the particle system */ pause(): void { + if (this._emitter) { + this._emitter.isEmitting = false; + } this._isPlaying = false; } /** - * 立即爆发发射 - * Burst emit + * 停止粒子系统 + * Stop the particle system * - * @param count - Number of particles to emit | 发射的粒子数量 - * @param worldX - World position X | 世界坐标X - * @param worldY - World position Y | 世界坐标Y + * @param clear 是否立即清除所有粒子 | Whether to immediately clear all particles */ - burst(count: number, worldX: number = 0, worldY: number = 0): void { - this._rebuildIfNeeded(); - if (!this._emitter || !this._pool) return; + stop(clear: boolean = false): void { + if (this._emitter) { + this._emitter.isEmitting = false; + } + this._isPlaying = false; + this._elapsedTime = 0; - this._emitter.burst(this._pool, count, worldX, worldY); + if (clear && this._pool) { + this._pool.recycleAll(); + } + + // 重置爆发状态 | Reset burst states + this._burstStates = []; + } + + /** + * 清除所有粒子 + * Clear all particles + */ + clear(): void { + this._pool?.recycleAll(); + } + + /** + * 触发一次爆发 + * Trigger a burst emission + * + * @param count 发射数量 | Number of particles to emit + */ + emit(count: number): void { + if (this._pool && this._emitter) { + this._emitter.burst(this._pool, count, this._lastEmitterX, this._lastEmitterY); + } } /** @@ -368,12 +491,22 @@ export class ParticleSystemComponent extends Component { * @param dt - Delta time in seconds | 时间增量(秒) * @param worldX - World position X for emission | 发射位置世界坐标X * @param worldY - World position Y for emission | 发射位置世界坐标Y + * @param worldRotation - World rotation in radians | 世界旋转(弧度) + * @param worldScaleX - World scale X | 世界缩放X + * @param worldScaleY - World scale Y | 世界缩放Y */ - update(dt: number, worldX: number = 0, worldY: number = 0): void { + update( + dt: number, + worldX: number = 0, + worldY: number = 0, + worldRotation: number = 0, + worldScaleX: number = 1, + worldScaleY: number = 1 + ): void { if (!this._isPlaying || !this._pool || !this._emitter) return; const scaledDt = dt * this.playbackSpeed; - this._simulate(scaledDt, worldX, worldY); + this._simulate(scaledDt, worldX, worldY, worldRotation, worldScaleX, worldScaleY); this._elapsedTime += scaledDt; // 检查持续时间 | Check duration @@ -385,6 +518,8 @@ export class ParticleSystemComponent extends Component { } } + // ============= 模块管理 | Module Management ============= + /** * 添加模块 * Add module @@ -415,6 +550,8 @@ export class ParticleSystemComponent extends Component { return false; } + // ============= 重建与标记 | Rebuild and Marking ============= + /** * 标记需要重建 * Mark for rebuild @@ -423,40 +560,105 @@ export class ParticleSystemComponent extends Component { this._needsRebuild = true; } + /** + * 检查并重建粒子系统(如果需要) + * Check and rebuild particle system if needed + * + * This method is called by ParticleUpdateSystem to ensure the particle system + * is built even when not playing. This allows property changes to take effect + * immediately in the editor. + * + * 此方法由 ParticleUpdateSystem 调用,确保即使未播放时也能重建粒子系统。 + * 这使得编辑器中的属性更改能够立即生效。 + */ + ensureBuilt(): void { + this._rebuildIfNeeded(); + } + private _rebuildIfNeeded(): void { if (!this._needsRebuild && this._pool && this._emitter) return; - // 创建/调整粒子池 | Create/resize particle pool - if (!this._pool) { - this._pool = new ParticlePool(this.maxParticles); - } else if (this._pool.capacity !== this.maxParticles) { - this._pool.resize(this.maxParticles); + // 必须有加载的资产才能构建 + // Must have loaded asset to build + const asset = this._loadedAsset; + if (!asset) { + // 没有资产时使用默认值创建最小系统 + // Create minimal system with defaults when no asset + if (!this._pool) { + this._pool = new ParticlePool(100); + } + if (!this._emitter) { + this._emitter = new ParticleEmitter(createDefaultEmitterConfig()); + } + this._needsRebuild = false; + return; } + // 应用运行时覆盖 | Apply runtime overrides + const overrides = this._runtimeOverrides; + const scaleMultiplier = overrides.scaleMultiplier ?? 1; + const speedMultiplier = overrides.speedMultiplier ?? 1; + + const maxParticles = asset.maxParticles; + const emissionRate = overrides.emissionRate ?? asset.emissionRate; + const emissionShape = asset.emissionShape; + const shapeRadius = asset.shapeRadius; + const shapeWidth = asset.shapeWidth; + const shapeHeight = asset.shapeHeight; + const lifetimeMin = asset.lifetimeMin; + const lifetimeMax = asset.lifetimeMax; + const speedMin = (asset.speedMin ?? 50) * speedMultiplier; + const speedMax = (asset.speedMax ?? 100) * speedMultiplier; + const direction = asset.direction ?? 90; + const directionSpread = asset.directionSpread ?? 0; + const scaleMin = (asset.scaleMin ?? 1) * scaleMultiplier; + const scaleMax = (asset.scaleMax ?? 1) * scaleMultiplier; + const gravityX = overrides.gravityX ?? asset.gravityX ?? 0; + const gravityY = overrides.gravityY ?? asset.gravityY ?? 0; + const startAlpha = asset.startAlpha ?? 1; + const endAlpha = asset.endAlpha ?? 0; + const endScale = asset.endScale ?? 0; + // 解析颜色 | Parse color - const color = this._parseColor(this.startColor); + let color: { r: number; g: number; b: number }; + if (overrides.startColor) { + color = { r: overrides.startColor.r, g: overrides.startColor.g, b: overrides.startColor.b }; + } else if (asset.startColor) { + color = { r: asset.startColor.r, g: asset.startColor.g, b: asset.startColor.b }; + } else { + color = { r: 1, g: 1, b: 1 }; + } + + // 创建/调整粒子池 | Create/resize particle pool + if (!this._pool) { + this._pool = new ParticlePool(maxParticles); + } else if (this._pool.capacity !== maxParticles) { + this._pool.resize(maxParticles); + } // 创建发射器配置 | Create emitter config + const directionRad = (direction - 90) * Math.PI / 180; + const config: EmitterConfig = { ...createDefaultEmitterConfig(), - emissionRate: this.emissionRate, + emissionRate, burstCount: 0, - lifetime: { min: this.lifetimeMin, max: this.lifetimeMax }, - shape: this.emissionShape, - shapeRadius: this.shapeRadius, - shapeWidth: this.shapeWidth, - shapeHeight: this.shapeHeight, + lifetime: { min: lifetimeMin, max: lifetimeMax }, + shape: emissionShape, + shapeRadius, + shapeWidth, + shapeHeight, coneAngle: Math.PI / 6, - direction: this.direction * Math.PI / 180, - directionSpread: this.directionSpread * Math.PI / 180, - speed: { min: this.speedMin, max: this.speedMax }, + direction: directionRad, + directionSpread: directionSpread * Math.PI / 180, + speed: { min: speedMin, max: speedMax }, angularVelocity: { min: 0, max: 0 }, - startScale: { min: this.scaleMin, max: this.scaleMax }, + startScale: { min: scaleMin, max: scaleMax }, startRotation: { min: 0, max: 0 }, - startColor: { ...color, a: this.startAlpha }, + startColor: { ...color, a: startAlpha }, startColorVariance: { r: 0, g: 0, b: 0, a: 0 }, - gravityX: this.gravityX, - gravityY: this.gravityY + gravityX, + gravityY: -gravityY }; if (!this._emitter) { @@ -471,149 +673,156 @@ export class ParticleSystemComponent extends Component { const colorModule = new ColorOverLifetimeModule(); colorModule.gradient = [ { time: 0, r: 1, g: 1, b: 1, a: 1 }, - { time: 1, r: 1, g: 1, b: 1, a: this.endAlpha } + { time: 1, r: 1, g: 1, b: 1, a: endAlpha } ]; this._modules.push(colorModule); // 缩放模块 | Size module const sizeModule = new SizeOverLifetimeModule(); sizeModule.startMultiplier = 1; - sizeModule.endMultiplier = this.endScale; + sizeModule.endMultiplier = endScale; this._modules.push(sizeModule); } this._needsRebuild = false; } - private _simulate(dt: number, worldX: number, worldY: number): void { + private _simulate( + dt: number, + worldX: number, + worldY: number, + worldRotation: number = 0, + worldScaleX: number = 1, + worldScaleY: number = 1 + ): void { if (!this._pool || !this._emitter) return; // 本地空间:计算发射器移动量 | Local space: calculate emitter movement const isLocalSpace = this.simulationSpace === SimulationSpace.Local; const emitterDeltaX = worldX - this._lastEmitterX; const emitterDeltaY = worldY - this._lastEmitterY; + this._lastEmitterX = worldX; + this._lastEmitterY = worldY; - // 发射新粒子 | Emit new particles - this._emitter.emit(this._pool, dt, worldX, worldY); + // 保存当前的变换参数,供渲染使用 | Save current transform params for rendering + this._worldRotation = worldRotation; + this._worldScaleX = worldScaleX; + this._worldScaleY = worldScaleY; - // 处理定时爆发 | Process timed bursts - this._processBursts(worldX, worldY); + // 发射新粒子(应用旋转到发射方向)| Emit new particles (apply rotation to emission direction) + this._emitter.emit(this._pool, dt, worldX, worldY, worldRotation, worldScaleX, worldScaleY); - // 查找碰撞模块并更新发射器位置 | Find collision module and update emitter position - const collisionModule = this._modules.find(m => m instanceof CollisionModule) as CollisionModule | undefined; - if (collisionModule) { - collisionModule.emitterX = worldX; - collisionModule.emitterY = worldY; - collisionModule.clearDeathFlags(); - } + // 处理爆发 | Process bursts + this._processBursts(worldX, worldY, worldRotation, worldScaleX, worldScaleY); - // 查找力场模块并更新发射器位置 | Find force field module and update emitter position - const forceFieldModule = this._modules.find(m => m instanceof ForceFieldModule) as ForceFieldModule | undefined; - if (forceFieldModule) { - forceFieldModule.emitterX = worldX; - forceFieldModule.emitterY = worldY; - } + // 更新现有粒子 | Update existing particles + const particles = this._pool.particles; + const particlesToRecycle: Particle[] = []; - // 更新粒子 | Update particles - this._pool.forEachActive((p) => { - // 本地空间:粒子跟随发射器移动 | Local space: particles follow emitter + for (const p of particles) { + if (!p.alive) continue; + + p.age += dt; + + if (p.age >= p.lifetime) { + particlesToRecycle.push(p); + continue; + } + + // 应用重力 | Apply gravity + const config = this._emitter.config; + p.vx += config.gravityX * dt; + p.vy += config.gravityY * dt; + + // 更新位置 | Update position + p.x += p.vx * dt; + p.y += p.vy * dt; + + // 本地空间:粒子跟随发射器 | Local space: particles follow emitter if (isLocalSpace) { p.x += emitterDeltaX; p.y += emitterDeltaY; } - // 物理更新 | Physics update - p.vx += p.ax * dt; - p.vy += p.ay * dt; - p.x += p.vx * dt; - p.y += p.vy * dt; - p.age += dt; + // 更新旋转 | Update rotation + p.rotation += p.angularVelocity * dt; // 应用模块 | Apply modules const normalizedAge = p.age / p.lifetime; for (const module of this._modules) { if (module.enabled) { - module.update(p, dt, normalizedAge); + module.update(p, normalizedAge, dt); } } - - // 检查生命周期 | Check lifetime - if (p.age >= p.lifetime) { - this._pool!.recycle(p); - } - }); - - // 处理碰撞模块标记的死亡粒子 | Process particles marked for death by collision module - if (collisionModule) { - const particlesToKill = collisionModule.getParticlesToKill(); - for (const p of particlesToKill) { - this._pool.recycle(p); - } } - // 记录发射器位置供下一帧使用 | Record emitter position for next frame - this._lastEmitterX = worldX; - this._lastEmitterY = worldY; + // 回收已过期的粒子 | Recycle expired particles + for (const p of particlesToRecycle) { + this._pool.recycle(p); + } } - /** - * 处理定时爆发 - * Process timed bursts - */ - private _processBursts(worldX: number, worldY: number): void { - if (!this._pool || !this._emitter || this.bursts.length === 0) return; + private _processBursts( + worldX: number, + worldY: number, + worldRotation: number = 0, + worldScaleX: number = 1, + worldScaleY: number = 1 + ): void { + const bursts = this.bursts; + if (!bursts || bursts.length === 0 || !this._pool || !this._emitter) return; - // 确保爆发状态数组与配置同步 | Ensure burst states array is synced with config - while (this._burstStates.length < this.bursts.length) { + // 初始化爆发状态 | Initialize burst states + while (this._burstStates.length < bursts.length) { this._burstStates.push({ firedCount: 0, lastFireTime: -Infinity }); } - const currentTime = this._elapsedTime; - - for (let i = 0; i < this.bursts.length; i++) { - const burst = this.bursts[i]; + for (let i = 0; i < bursts.length; i++) { + const burst = bursts[i]; const state = this._burstStates[i]; - // 检查是否已达到循环次数上限 | Check if reached cycle limit - if (burst.cycles > 0 && state.firedCount >= burst.cycles) { - continue; - } + // 检查是否达到触发时间 | Check if trigger time reached + if (this._elapsedTime >= burst.time) { + // 检查循环次数 | Check cycle count + const maxCycles = burst.cycles === 0 ? Infinity : burst.cycles; + if (state.firedCount >= maxCycles) continue; - // 计算下次触发时间 | Calculate next fire time - let nextFireTime: number; - if (state.firedCount === 0) { - // 首次触发 | First fire - nextFireTime = burst.time; - } else { - // 循环触发 | Cycle fire - nextFireTime = state.lastFireTime + burst.interval; - } + // 检查间隔 | Check interval + const timeSinceLastFire = this._elapsedTime - state.lastFireTime; + const interval = state.firedCount === 0 ? 0 : burst.interval; - // 检查是否应该触发 | Check if should fire - if (currentTime >= nextFireTime) { - this._emitter.burst(this._pool, burst.count, worldX, worldY); - state.firedCount++; - state.lastFireTime = currentTime; + if (timeSinceLastFire >= interval) { + this._emitter.burst(this._pool, burst.count, worldX, worldY, worldRotation, worldScaleX, worldScaleY); + state.firedCount++; + state.lastFireTime = this._elapsedTime; + } } } } - private _parseColor(hex: string): { r: number; g: number; b: number } { - const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); - if (result) { - return { - r: parseInt(result[1], 16) / 255, - g: parseInt(result[2], 16) / 255, - b: parseInt(result[3], 16) / 255 - }; - } - return { r: 1, g: 1, b: 1 }; - } + // ============= 清理 | Cleanup ============= - onDestroy(): void { - this._pool?.recycleAll(); + /** + * 重置粒子系统到初始状态 + * Reset particle system to initial state + */ + resetSystem(): void { + this.stop(true); this._pool = null; this._emitter = null; this._modules = []; + this._needsRebuild = true; + this._loadedAsset = null; + this._lastLoadedGuid = ''; + this._runtimeOverrides = {}; + this.textureId = 0; + } + + /** + * 组件从实体移除时的回调 + * Called when component is removed from entity + */ + override onRemovedFromEntity(): void { + this.resetSystem(); } } diff --git a/packages/particle/src/loaders/ParticleLoader.ts b/packages/particle/src/loaders/ParticleLoader.ts index c58437fd..468e07df 100644 --- a/packages/particle/src/loaders/ParticleLoader.ts +++ b/packages/particle/src/loaders/ParticleLoader.ts @@ -31,6 +31,21 @@ export interface IParticleModuleConfig { params: Record; } +/** + * 爆发配置 + * Burst configuration + */ +export interface IBurstConfig { + /** 触发时间(秒)| Trigger time (seconds) */ + time: number; + /** 发射数量 | Particle count */ + count: number; + /** 循环次数(0=无限)| Number of cycles (0=infinite) */ + cycles: number; + /** 循环间隔(秒)| Interval between cycles (seconds) */ + interval: number; +} + /** * 粒子效果资源数据接口 * Particle effect asset data interface @@ -110,11 +125,17 @@ export interface IParticleAsset { sortingOrder: number; /** 纹理资产 GUID | Texture asset GUID */ textureGuid?: string; + /** 纹理路径(编辑器兼容)| Texture path (editor compatibility) */ + texturePath?: string; // 模块配置 | Module configurations /** 模块列表 | Module list */ modules?: IParticleModuleConfig[]; + // 爆发配置 | Burst configurations + /** 爆发列表 | Burst list */ + bursts?: IBurstConfig[]; + // 纹理动画(可选)| Texture animation (optional) /** 纹理图集列数 | Texture sheet columns */ textureTilesX?: number; diff --git a/packages/particle/src/loaders/index.ts b/packages/particle/src/loaders/index.ts index babe5fb7..a4f4f7cd 100644 --- a/packages/particle/src/loaders/index.ts +++ b/packages/particle/src/loaders/index.ts @@ -3,5 +3,6 @@ export { ParticleAssetType, createDefaultParticleAsset, type IParticleAsset, - type IParticleModuleConfig + type IParticleModuleConfig, + type IBurstConfig } from './ParticleLoader'; diff --git a/packages/particle/src/rendering/ParticleRenderDataProvider.ts b/packages/particle/src/rendering/ParticleRenderDataProvider.ts index 3d5d1677..b5071db8 100644 --- a/packages/particle/src/rendering/ParticleRenderDataProvider.ts +++ b/packages/particle/src/rendering/ParticleRenderDataProvider.ts @@ -106,6 +106,7 @@ export class ParticleRenderDataProvider implements IRenderDataProvider { } } + if (totalParticles === 0) return; // 确保缓冲区足够大 | Ensure buffers are large enough @@ -183,6 +184,11 @@ export class ParticleRenderDataProvider implements IRenderDataProvider { } if (particleIndex > 0) { + // 获取纹理路径(支持多种来源)| Get texture path (support multiple sources) + const firstComponent = systems[0]?.component; + const asset = firstComponent?.loadedAsset as { textureGuid?: string; texturePath?: string } | null; + const texPath = asset?.textureGuid || asset?.texturePath || firstComponent?.textureGuid || undefined; + // 创建当前组的渲染数据 | Create render data for current group const renderData: ParticleProviderRenderData = { transforms: this._transforms.subarray(0, particleIndex * 7), @@ -191,7 +197,7 @@ export class ParticleRenderDataProvider implements IRenderDataProvider { colors: this._colors.subarray(0, particleIndex), tileCount: particleIndex, sortingOrder, - texturePath: systems[0]?.component.textureGuid || undefined + texturePath: texPath }; this._renderDataCache.push(renderData); diff --git a/packages/particle/src/systems/ParticleSystem.ts b/packages/particle/src/systems/ParticleSystem.ts index 2ceb6aa7..d0c2d4f3 100644 --- a/packages/particle/src/systems/ParticleSystem.ts +++ b/packages/particle/src/systems/ParticleSystem.ts @@ -1,6 +1,43 @@ import { EntitySystem, Matcher, ECSSystem, Time, Entity } from '@esengine/ecs-framework'; import { ParticleSystemComponent } from '../ParticleSystemComponent'; import { ParticleRenderDataProvider } from '../rendering/ParticleRenderDataProvider'; +import type { IEngineIntegration, IEngineBridge } from '../ParticleRuntimeModule'; + +/** + * 默认粒子纹理 ID + * Default particle texture ID + */ +const DEFAULT_PARTICLE_TEXTURE_ID = 99999; + +/** + * 生成默认粒子纹理的 Data URL(渐变圆形) + * Generate default particle texture Data URL (gradient circle) + */ +function generateDefaultParticleTextureDataURL(): string { + const size = 64; + const canvas = document.createElement('canvas'); + canvas.width = size; + canvas.height = size; + const ctx = canvas.getContext('2d'); + if (!ctx) return ''; + + // 创建径向渐变 | Create radial gradient + const gradient = ctx.createRadialGradient( + size / 2, size / 2, 0, + size / 2, size / 2, size / 2 + ); + gradient.addColorStop(0, 'rgba(255, 255, 255, 1)'); + gradient.addColorStop(0.4, 'rgba(255, 255, 255, 0.8)'); + gradient.addColorStop(0.7, 'rgba(255, 255, 255, 0.3)'); + gradient.addColorStop(1, 'rgba(255, 255, 255, 0)'); + + ctx.fillStyle = gradient; + ctx.beginPath(); + ctx.arc(size / 2, size / 2, size / 2, 0, Math.PI * 2); + ctx.fill(); + + return canvas.toDataURL('image/png'); +} /** * Transform 组件接口(避免直接依赖 engine-core) @@ -9,6 +46,14 @@ import { ParticleRenderDataProvider } from '../rendering/ParticleRenderDataProvi interface ITransformComponent { worldPosition?: { x: number; y: number; z: number }; position: { x: number; y: number; z: number }; + /** 世界旋转(Vector3,z 分量为 2D 旋转弧度)| World rotation (Vector3, z component is 2D rotation in radians) */ + worldRotation?: { x: number; y: number; z: number }; + /** 本地旋转(Vector3)| Local rotation (Vector3) */ + rotation?: { x: number; y: number; z: number }; + /** 世界缩放 | World scale */ + worldScale?: { x: number; y: number; z: number }; + /** 本地缩放 | Local scale */ + scale?: { x: number; y: number; z: number }; } /** @@ -22,6 +67,14 @@ interface ITransformComponent { export class ParticleUpdateSystem extends EntitySystem { private _transformType: (new (...args: any[]) => ITransformComponent) | null = null; private _renderDataProvider: ParticleRenderDataProvider; + private _engineIntegration: IEngineIntegration | null = null; + private _engineBridge: IEngineBridge | null = null; + private _defaultTextureLoaded: boolean = false; + private _defaultTextureLoading: boolean = false; + /** 追踪每个粒子组件上次加载的资产 GUID | Track last loaded asset GUID for each particle component */ + private _lastLoadedGuids: WeakMap = new WeakMap(); + /** 正在加载资产的粒子组件 | Particle components currently loading assets */ + private _loadingComponents: WeakSet = new WeakSet(); constructor() { super(Matcher.empty().all(ParticleSystemComponent)); @@ -38,6 +91,22 @@ export class ParticleUpdateSystem extends EntitySystem { this._transformType = transformType; } + /** + * 设置引擎集成(用于加载纹理) + * Set engine integration (for loading textures) + */ + setEngineIntegration(integration: IEngineIntegration): void { + this._engineIntegration = integration; + } + + /** + * 设置引擎桥接(用于加载默认纹理) + * Set engine bridge (for loading default texture) + */ + setEngineBridge(bridge: IEngineBridge): void { + this._engineBridge = bridge; + } + /** * 获取渲染数据提供者 * Get render data provider @@ -57,26 +126,61 @@ export class ParticleUpdateSystem extends EntitySystem { let worldX = 0; let worldY = 0; + let worldRotation = 0; + let worldScaleX = 1; + let worldScaleY = 1; let transform: ITransformComponent | null = null; - // 获取 Transform 位置 | Get Transform position + // 获取 Transform 位置、旋转、缩放 | Get Transform position, rotation, scale if (this._transformType) { transform = entity.getComponent(this._transformType as any) as ITransformComponent | null; if (transform) { const pos = transform.worldPosition ?? transform.position; worldX = pos.x; worldY = pos.y; + + // 获取旋转(2D 使用 z 分量)| Get rotation (2D uses z component) + const rot = transform.worldRotation ?? transform.rotation; + if (rot) { + worldRotation = rot.z; + } + + // 获取缩放 | Get scale + const scale = transform.worldScale ?? transform.scale; + if (scale) { + worldScaleX = scale.x; + worldScaleY = scale.y; + } } } + // 检测资产 GUID 变化并重新加载 | Detect asset GUID change and reload + // 这使得编辑器中选择新的粒子资产时能够立即切换 + // This allows immediate switching when selecting a new particle asset in the editor + this._checkAndReloadAsset(particle); + + // 确保粒子系统已构建(即使未播放)| Ensure particle system is built (even when not playing) + // 这使得编辑器中的属性更改能够立即生效 + // This allows property changes to take effect immediately in the editor + particle.ensureBuilt(); + // 更新粒子系统 | Update particle system if (particle.isPlaying) { - particle.update(deltaTime, worldX, worldY); + particle.update(deltaTime, worldX, worldY, worldRotation, worldScaleX, worldScaleY); + } + + // 尝试加载纹理(如果还没有加载)| Try to load texture if not loaded yet + if (particle.textureId === 0) { + this.loadParticleTexture(particle); } // 更新渲染数据提供者的 Transform 引用 | Update render data provider's Transform reference + // 确保粒子系统始终被注册 | Ensure particle system is always registered if (transform) { this._renderDataProvider.register(particle, transform); + } else { + // 使用默认 Transform | Use default transform + this._renderDataProvider.register(particle, { position: { x: worldX, y: worldY } }); } } @@ -87,18 +191,176 @@ export class ParticleUpdateSystem extends EntitySystem { protected override onAdded(entity: Entity): void { const particle = entity.getComponent(ParticleSystemComponent) as ParticleSystemComponent | null; if (particle) { - particle.initialize(); + // 异步初始化粒子系统 | Async initialize particle system + this._initializeParticle(entity, particle); + } + } - // 注册到渲染数据提供者 | Register to render data provider - if (this._transformType) { - const transform = entity.getComponent(this._transformType as any) as ITransformComponent | null; - if (transform) { - this._renderDataProvider.register(particle, transform); - } + /** + * 异步初始化粒子系统 + * Async initialize particle system + */ + private async _initializeParticle(entity: Entity, particle: ParticleSystemComponent): Promise { + // 如果有资产 GUID,先加载资产 | Load asset first if GUID is set + if (particle.particleAssetGuid) { + await particle.loadAsset(particle.particleAssetGuid); + } + + // 初始化粒子系统(不自动播放,由下面的逻辑控制) + // Initialize particle system (don't auto play, controlled by logic below) + particle.ensureBuilt(); + + // 加载纹理 | Load texture + await this.loadParticleTexture(particle); + + // 注册到渲染数据提供者 | Register to render data provider + // 尝试获取 Transform,如果没有则使用默认位置 | Try to get Transform, use default position if not available + let transform: ITransformComponent | null = null; + if (this._transformType) { + transform = entity.getComponent(this._transformType as any) as ITransformComponent | null; + } + // 即使没有 Transform,也要注册粒子系统(使用原点位置) | Register particle system even without Transform (use origin position) + if (transform) { + this._renderDataProvider.register(particle, transform); + } else { + this._renderDataProvider.register(particle, { position: { x: 0, y: 0 } }); + } + + // 记录已加载的资产 GUID | Record loaded asset GUID + this._lastLoadedGuids.set(particle, particle.particleAssetGuid); + + // 决定是否自动播放 | Decide whether to auto play + // 编辑器模式:有资产时自动播放预览 | Editor mode: auto play preview if has asset + // 运行时模式:根据 autoPlay 设置 | Runtime mode: based on autoPlay setting + const isEditorMode = this.scene?.isEditorMode ?? false; + if (particle.particleAssetGuid && particle.loadedAsset) { + if (isEditorMode) { + // 编辑器模式:始终播放预览 | Editor mode: always play preview + particle.play(); + } else if (particle.autoPlay) { + // 运行时模式:根据 autoPlay 设置 | Runtime mode: based on autoPlay + particle.play(); } } } + /** + * 检测资产 GUID 变化并重新加载 + * Check for asset GUID change and reload if necessary + * + * 当编辑器中修改 particleAssetGuid 属性时,此方法会检测变化并触发重新加载。 + * 加载完成后会自动开始播放预览,让用户立即看到效果。 + * + * When particleAssetGuid property is modified in editor, this method detects the change and triggers reload. + * After loading, it automatically starts playback for preview so user can see the effect immediately. + */ + private _checkAndReloadAsset(particle: ParticleSystemComponent): void { + const currentGuid = particle.particleAssetGuid; + const lastGuid = this._lastLoadedGuids.get(particle); + + // 如果 GUID 没有变化,或者正在加载中,跳过 + // Skip if GUID hasn't changed or already loading + if (currentGuid === lastGuid || this._loadingComponents.has(particle)) { + return; + } + + // 标记为正在加载 | Mark as loading + this._loadingComponents.add(particle); + this._lastLoadedGuids.set(particle, currentGuid); + + // 停止当前播放并清除粒子 | Stop current playback and clear particles + particle.stop(true); + + // 重置纹理 ID,以便重新加载纹理 | Reset texture ID for texture reload + particle.textureId = 0; + + // 异步加载新资产 | Async load new asset + (async () => { + try { + if (currentGuid) { + await particle.loadAsset(currentGuid); + // 加载纹理 | Load texture + await this.loadParticleTexture(particle); + + // 标记需要重建 | Mark for rebuild + particle.markDirty(); + + // 在编辑器中自动播放预览,让用户立即看到效果 + // Auto play preview in editor so user can see the effect immediately + particle.play(); + + console.log(`[ParticleUpdateSystem] Asset loaded and playing: ${currentGuid}`); + } else { + // 清空资产时,设置为 null | Clear asset when GUID is empty + particle.setAssetData(null); + particle.markDirty(); + console.log(`[ParticleUpdateSystem] Asset cleared`); + } + } catch (error) { + console.error('[ParticleUpdateSystem] Failed to reload asset:', error); + } finally { + // 取消加载标记 | Remove loading mark + this._loadingComponents.delete(particle); + } + })(); + } + + /** + * 加载粒子纹理 + * Load particle texture + */ + async loadParticleTexture(particle: ParticleSystemComponent): Promise { + if (!this._engineIntegration) { + return; + } + + // 已经加载过就跳过 | Skip if already loaded + if (particle.textureId > 0) return; + + // 从已加载的资产获取纹理路径 | Get texture path from loaded asset + // 支持 textureGuid 和 texturePath(编辑器可能使用后者) + // Support both textureGuid and texturePath (editor may use the latter) + const asset = particle.loadedAsset; + const texturePath = asset?.textureGuid || asset?.texturePath || particle.textureGuid; + + if (texturePath) { + try { + const textureId = await this._engineIntegration.loadTextureForComponent(texturePath); + particle.textureId = textureId; + } catch (error) { + console.error('[ParticleUpdateSystem] Failed to load texture:', texturePath, error); + // 加载失败时使用默认纹理 | Use default texture on load failure + await this._ensureDefaultTexture(); + particle.textureId = DEFAULT_PARTICLE_TEXTURE_ID; + } + } else { + // 没有纹理路径时使用默认粒子纹理 | Use default particle texture when no path + await this._ensureDefaultTexture(); + particle.textureId = DEFAULT_PARTICLE_TEXTURE_ID; + } + } + + /** + * 确保默认粒子纹理已加载 + * Ensure default particle texture is loaded + */ + private async _ensureDefaultTexture(): Promise { + if (this._defaultTextureLoaded || this._defaultTextureLoading) return; + if (!this._engineBridge) return; + + this._defaultTextureLoading = true; + try { + const dataUrl = generateDefaultParticleTextureDataURL(); + if (dataUrl) { + await this._engineBridge.loadTexture(DEFAULT_PARTICLE_TEXTURE_ID, dataUrl); + this._defaultTextureLoaded = true; + } + } catch (error) { + console.error('[ParticleUpdateSystem] Failed to create default particle texture:', error); + } + this._defaultTextureLoading = false; + } + protected override onRemoved(entity: Entity): void { const particle = entity.getComponent(ParticleSystemComponent) as ParticleSystemComponent | null; if (particle) { diff --git a/packages/platform-web/src/BrowserRuntime.ts b/packages/platform-web/src/BrowserRuntime.ts index dec80e5e..9e1895d4 100644 --- a/packages/platform-web/src/BrowserRuntime.ts +++ b/packages/platform-web/src/BrowserRuntime.ts @@ -18,7 +18,7 @@ import { BrowserFileSystemService, type IPlugin } from '@esengine/runtime-core'; -import type { IAssetManager } from '@esengine/asset-system'; +import { assetManager as globalAssetManager, type IAssetManager, type IAssetCatalog, type IAssetCatalogEntry } from '@esengine/asset-system'; import { BrowserAssetReader } from './BrowserAssetReader'; /** @@ -133,9 +133,50 @@ export class BrowserRuntime { Core.services.registerInstance(IFileSystemServiceKey, this._fileSystem); } - // Set asset reader for AssetManager - if (this._runtime.assetManager && this._assetReader) { - this._runtime.assetManager.setReader(this._assetReader); + // Set asset reader for AssetManager (both runtime instance and global singleton) + // 设置资产读取器(运行时实例和全局单例) + if (this._assetReader) { + // Initialize the GLOBAL assetManager singleton (used by particle and other modules) + // 初始化全局 assetManager 单例(被 particle 等模块使用) + globalAssetManager.setReader(this._assetReader); + + // Also set for runtime's assetManager if available + if (this._runtime.assetManager) { + this._runtime.assetManager.setReader(this._assetReader); + } + + // Initialize AssetManager with catalog data from BrowserFileSystemService + // 使用 BrowserFileSystemService 的 catalog 数据初始化 AssetManager + if (this._fileSystem?.catalog) { + const browserCatalog = this._fileSystem.catalog; + const assetCatalog: IAssetCatalog = { + version: browserCatalog.version, + createdAt: browserCatalog.createdAt, + entries: new Map(), + bundles: new Map() + }; + + // Convert browser catalog entries to IAssetCatalog format + // 将浏览器 catalog 条目转换为 IAssetCatalog 格式 + for (const [guid, entry] of Object.entries(browserCatalog.entries)) { + assetCatalog.entries.set(guid, { + guid: entry.guid, + path: entry.path, + type: entry.type, + size: entry.size, + hash: entry.hash + }); + } + + // Initialize GLOBAL assetManager singleton (this is what particle module uses) + // 初始化全局 assetManager 单例(particle 模块使用的就是这个) + globalAssetManager.initializeFromCatalog(assetCatalog); + + // Also initialize runtime's assetManager if available + if (this._runtime.assetManager) { + this._runtime.assetManager.initializeFromCatalog(assetCatalog); + } + } } // Disable editor mode (hides grid, gizmos, axis indicator) diff --git a/packages/runtime-core/src/GameRuntime.ts b/packages/runtime-core/src/GameRuntime.ts index 4b3d78a4..928399e8 100644 --- a/packages/runtime-core/src/GameRuntime.ts +++ b/packages/runtime-core/src/GameRuntime.ts @@ -270,6 +270,7 @@ export class GameRuntime { this._systemContext = { isEditor: this._platform.isEditorMode(), engineBridge: this._bridge, + engineIntegration: this._engineIntegration, renderSystem: this._renderSystem, assetManager: this._assetManager, inputSystem: this._inputSystem, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7bcf994f..3287d6ee 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1050,10 +1050,11 @@ importers: version: 5.0.8(@types/react@18.3.27)(react@18.3.1)(use-sync-external-store@1.6.0(react@18.3.1)) packages/particle: - devDependencies: + dependencies: '@esengine/asset-system': specifier: workspace:* version: link:../asset-system + devDependencies: '@esengine/build-config': specifier: workspace:* version: link:../build-config