fix(particle): 修复粒子系统在浏览器预览中的资产加载和渲染 (#290)
* 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 方法供运行时使用
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -28,6 +28,7 @@ function getIconComponent(iconName: string | undefined, size: number = 14): Reac
|
||||
const categoryIconMap: Record<string, string> = {
|
||||
'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);
|
||||
|
||||
@@ -843,6 +843,12 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) {
|
||||
// 从场景中收集所有资产路径
|
||||
const sceneObj = JSON.parse(sceneData);
|
||||
const assetPaths = new Set<string>();
|
||||
// GUID 到路径的映射,用于需要通过 GUID 加载的资产
|
||||
// GUID to path mapping for assets that need to be loaded by GUID
|
||||
const guidToPath = new Map<string, string>();
|
||||
|
||||
// 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
|
||||
|
||||
@@ -11,19 +11,46 @@ const VectorInput: React.FC<{
|
||||
onChange: (value: number) => void;
|
||||
readonly?: boolean;
|
||||
axis: 'x' | 'y' | 'z' | 'w';
|
||||
}> = ({ label, value, onChange, readonly, axis }) => (
|
||||
<div className="property-vector-axis-compact">
|
||||
<span className={`property-vector-axis-label property-vector-axis-${axis}`}>{label}</span>
|
||||
<input
|
||||
type="number"
|
||||
value={value}
|
||||
onChange={(e) => onChange(parseFloat(e.target.value) || 0)}
|
||||
disabled={readonly}
|
||||
step={0.1}
|
||||
className="property-input property-input-number property-input-number-compact"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
step?: number;
|
||||
}> = ({ label, value, onChange, readonly, axis, step = 0.01 }) => {
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
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<HTMLInputElement>) => {
|
||||
// 失去焦点时,如果是无效值则重置为当前值
|
||||
// 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 (
|
||||
<div className="property-vector-axis-compact">
|
||||
<span className={`property-vector-axis-label property-vector-axis-${axis}`}>{label}</span>
|
||||
<input
|
||||
type="number"
|
||||
defaultValue={value}
|
||||
key={value} // 强制在外部值变化时重新渲染
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
disabled={readonly}
|
||||
step={step}
|
||||
className="property-input property-input-number property-input-number-compact"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export class Vector2FieldEditor implements IFieldEditor<Vector2> {
|
||||
readonly type = 'vector2';
|
||||
|
||||
@@ -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<void> {
|
||||
// 注册检视器提供者 | 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<void> {
|
||||
// 清理 | 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[] {
|
||||
|
||||
310
packages/particle-editor/src/gizmos/ParticleGizmo.ts
Normal file
310
packages/particle-editor/src/gizmos/ParticleGizmo.ts
Normal file
@@ -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);
|
||||
}
|
||||
@@ -23,3 +23,6 @@ export type { ParticleEditorState } from './stores/ParticleEditorStore';
|
||||
|
||||
// Components
|
||||
export { GradientEditor, CurveEditor } from './components';
|
||||
|
||||
// Gizmos
|
||||
export { registerParticleGizmo, unregisterParticleGizmo } from './gizmos/ParticleGizmo';
|
||||
|
||||
@@ -2003,6 +2003,18 @@ export function ParticleEditorPanel() {
|
||||
}
|
||||
}, [particleData, filePath, setFilePath, markSaved]);
|
||||
|
||||
// 面板容器 ref | Panel container ref
|
||||
const panelRef = useRef<HTMLDivElement>(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 (
|
||||
<div className="particle-editor-panel">
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="particle-editor-panel"
|
||||
tabIndex={0}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
{/* Toolbar */}
|
||||
<div className="particle-editor-toolbar">
|
||||
<div className="toolbar-left">
|
||||
|
||||
@@ -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 = <K extends keyof ParticleSystemComponent>(
|
||||
key: K,
|
||||
value: ParticleSystemComponent[K]
|
||||
) => {
|
||||
(component as any)[key] = value;
|
||||
component.markDirty();
|
||||
refresh();
|
||||
};
|
||||
const hasAsset = !!component.particleAssetGuid;
|
||||
const isAssetLoaded = !!component.loadedAsset;
|
||||
|
||||
return (
|
||||
<div className="entity-inspector">
|
||||
@@ -83,247 +113,50 @@ function ParticleInspectorUI({ data }: ParticleInspectorUIProps) {
|
||||
onClick={component.isPlaying ? handlePause : handlePlay}
|
||||
style={buttonStyle}
|
||||
title={component.isPlaying ? 'Pause' : 'Play'}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{component.isPlaying ? <Pause size={14} /> : <Play size={14} />}
|
||||
{isLoading ? <Loader2 size={14} className="animate-spin" /> :
|
||||
component.isPlaying ? <Pause size={14} /> : <Play size={14} />}
|
||||
</button>
|
||||
<button onClick={handleStop} style={buttonStyle} title="Stop">
|
||||
<button onClick={handleStop} style={buttonStyle} title="Stop" disabled={isLoading}>
|
||||
<RotateCcw size={14} />
|
||||
</button>
|
||||
<button onClick={handleBurst} style={buttonStyle} title="Burst 10">
|
||||
<button onClick={handleBurst} style={buttonStyle} title="Burst 10" disabled={isLoading}>
|
||||
<Sparkles size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="property-row">
|
||||
<label>Active Particles</label>
|
||||
<span>{component.activeParticleCount} / {component.maxParticles}</span>
|
||||
<span>{component.activeParticleCount} / {component.loadedAsset?.maxParticles ?? component.maxParticles}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 基础属性 | Basic Properties */}
|
||||
<div className="inspector-section">
|
||||
<div className="section-title">Basic</div>
|
||||
|
||||
<NumberInput
|
||||
label="Max Particles"
|
||||
value={component.maxParticles}
|
||||
min={1}
|
||||
max={10000}
|
||||
step={100}
|
||||
onChange={v => handleChange('maxParticles', v)}
|
||||
/>
|
||||
|
||||
<CheckboxInput
|
||||
label="Looping"
|
||||
checked={component.looping}
|
||||
onChange={v => handleChange('looping', v)}
|
||||
/>
|
||||
|
||||
<NumberInput
|
||||
label="Duration"
|
||||
value={component.duration}
|
||||
min={0.1}
|
||||
step={0.1}
|
||||
onChange={v => handleChange('duration', v)}
|
||||
/>
|
||||
|
||||
<NumberInput
|
||||
label="Playback Speed"
|
||||
value={component.playbackSpeed}
|
||||
min={0.01}
|
||||
max={10}
|
||||
step={0.1}
|
||||
onChange={v => handleChange('playbackSpeed', v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 发射属性 | Emission Properties */}
|
||||
<div className="inspector-section">
|
||||
<div className="section-title">Emission</div>
|
||||
|
||||
<NumberInput
|
||||
label="Emission Rate"
|
||||
value={component.emissionRate}
|
||||
min={0}
|
||||
step={1}
|
||||
onChange={v => handleChange('emissionRate', v)}
|
||||
/>
|
||||
|
||||
<SelectInput
|
||||
label="Shape"
|
||||
value={component.emissionShape}
|
||||
options={[
|
||||
{ value: EmissionShape.Point, label: 'Point' },
|
||||
{ value: EmissionShape.Circle, label: 'Circle (filled)' },
|
||||
{ value: EmissionShape.Ring, label: 'Ring (edge)' },
|
||||
{ value: EmissionShape.Rectangle, label: 'Rectangle (filled)' },
|
||||
{ value: EmissionShape.Edge, label: 'Edge (rect outline)' },
|
||||
{ value: EmissionShape.Line, label: 'Line' },
|
||||
{ value: EmissionShape.Cone, label: 'Cone' },
|
||||
]}
|
||||
onChange={v => handleChange('emissionShape', v as EmissionShape)}
|
||||
/>
|
||||
|
||||
{component.emissionShape !== EmissionShape.Point && (
|
||||
<NumberInput
|
||||
label="Shape Radius"
|
||||
value={component.shapeRadius}
|
||||
min={0}
|
||||
step={1}
|
||||
onChange={v => handleChange('shapeRadius', v)}
|
||||
/>
|
||||
{hasAsset && (
|
||||
<div className="property-row">
|
||||
<label>Asset Status</label>
|
||||
<span style={{ color: isAssetLoaded ? 'var(--color-success, #4caf50)' : 'var(--color-warning, #ff9800)' }}>
|
||||
{isLoading ? 'Loading...' : isAssetLoaded ? 'Loaded' : 'Not loaded'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 粒子属性 | Particle Properties */}
|
||||
<div className="inspector-section">
|
||||
<div className="section-title">Particle</div>
|
||||
|
||||
<RangeInput
|
||||
label="Lifetime"
|
||||
minValue={component.lifetimeMin}
|
||||
maxValue={component.lifetimeMax}
|
||||
min={0.01}
|
||||
step={0.1}
|
||||
onMinChange={v => handleChange('lifetimeMin', v)}
|
||||
onMaxChange={v => handleChange('lifetimeMax', v)}
|
||||
/>
|
||||
|
||||
<RangeInput
|
||||
label="Speed"
|
||||
minValue={component.speedMin}
|
||||
maxValue={component.speedMax}
|
||||
min={0}
|
||||
step={1}
|
||||
onMinChange={v => handleChange('speedMin', v)}
|
||||
onMaxChange={v => handleChange('speedMax', v)}
|
||||
/>
|
||||
|
||||
<NumberInput
|
||||
label="Direction (°)"
|
||||
value={component.direction}
|
||||
min={-180}
|
||||
max={180}
|
||||
step={1}
|
||||
onChange={v => handleChange('direction', v)}
|
||||
/>
|
||||
|
||||
<NumberInput
|
||||
label="Spread (°)"
|
||||
value={component.directionSpread}
|
||||
min={0}
|
||||
max={360}
|
||||
step={1}
|
||||
onChange={v => handleChange('directionSpread', v)}
|
||||
/>
|
||||
|
||||
<RangeInput
|
||||
label="Scale"
|
||||
minValue={component.scaleMin}
|
||||
maxValue={component.scaleMax}
|
||||
min={0.01}
|
||||
step={0.1}
|
||||
onMinChange={v => handleChange('scaleMin', v)}
|
||||
onMaxChange={v => handleChange('scaleMax', v)}
|
||||
/>
|
||||
|
||||
<NumberInput
|
||||
label="Gravity X"
|
||||
value={component.gravityX}
|
||||
step={1}
|
||||
onChange={v => handleChange('gravityX', v)}
|
||||
/>
|
||||
|
||||
<NumberInput
|
||||
label="Gravity Y"
|
||||
value={component.gravityY}
|
||||
step={1}
|
||||
onChange={v => handleChange('gravityY', v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 颜色属性 | Color Properties */}
|
||||
<div className="inspector-section">
|
||||
<div className="section-title">Color</div>
|
||||
|
||||
<ColorInput
|
||||
label="Start Color"
|
||||
value={component.startColor}
|
||||
onChange={v => handleChange('startColor', v)}
|
||||
/>
|
||||
|
||||
<NumberInput
|
||||
label="Start Alpha"
|
||||
value={component.startAlpha}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
onChange={v => handleChange('startAlpha', v)}
|
||||
/>
|
||||
|
||||
<NumberInput
|
||||
label="End Alpha"
|
||||
value={component.endAlpha}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
onChange={v => handleChange('endAlpha', v)}
|
||||
/>
|
||||
|
||||
<NumberInput
|
||||
label="End Scale"
|
||||
value={component.endScale}
|
||||
min={0}
|
||||
step={0.1}
|
||||
onChange={v => handleChange('endScale', v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 渲染属性 | Rendering Properties */}
|
||||
<div className="inspector-section">
|
||||
<div className="section-title">Rendering</div>
|
||||
|
||||
<NumberInput
|
||||
label="Particle Size"
|
||||
value={component.particleSize}
|
||||
min={1}
|
||||
step={1}
|
||||
onChange={v => handleChange('particleSize', v)}
|
||||
/>
|
||||
|
||||
<SelectInput
|
||||
label="Blend Mode"
|
||||
value={component.blendMode}
|
||||
options={[
|
||||
{ value: ParticleBlendMode.Normal, label: 'Normal' },
|
||||
{ value: ParticleBlendMode.Additive, label: 'Additive' },
|
||||
{ value: ParticleBlendMode.Multiply, label: 'Multiply' },
|
||||
]}
|
||||
onChange={v => handleChange('blendMode', v as ParticleBlendMode)}
|
||||
/>
|
||||
|
||||
<NumberInput
|
||||
label="Sorting Order"
|
||||
value={component.sortingOrder}
|
||||
step={1}
|
||||
onChange={v => handleChange('sortingOrder', v)}
|
||||
/>
|
||||
</div>
|
||||
{/* 提示信息 | Hint */}
|
||||
{!hasAsset && (
|
||||
<div style={{
|
||||
padding: '8px 12px',
|
||||
background: 'var(--color-warning-bg, rgba(255, 193, 7, 0.1))',
|
||||
border: '1px solid var(--color-warning-border, rgba(255, 193, 7, 0.3))',
|
||||
borderRadius: '4px',
|
||||
fontSize: '11px',
|
||||
color: 'var(--text-color, #e0e0e0)',
|
||||
lineHeight: 1.4
|
||||
}}>
|
||||
No particle asset selected. Drag a <code>.particle</code> file to the Particle Asset field above, or create one in Content Browser.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============= 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 (
|
||||
<div className="property-row">
|
||||
<label>{label}</label>
|
||||
<input
|
||||
type="number"
|
||||
value={value}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
onChange={e => onChange(parseFloat(e.target.value) || 0)}
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="property-row">
|
||||
<label>{label}</label>
|
||||
<div style={{ display: 'flex', gap: '4px', flex: 1 }}>
|
||||
<input
|
||||
type="number"
|
||||
value={minValue}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
onChange={e => onMinChange(parseFloat(e.target.value) || 0)}
|
||||
style={{ ...inputStyle, width: '50%' }}
|
||||
title="Min"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
value={maxValue}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
onChange={e => onMaxChange(parseFloat(e.target.value) || 0)}
|
||||
style={{ ...inputStyle, width: '50%' }}
|
||||
title="Max"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CheckboxInputProps {
|
||||
label: string;
|
||||
checked: boolean;
|
||||
onChange: (value: boolean) => void;
|
||||
}
|
||||
|
||||
function CheckboxInput({ label, checked, onChange }: CheckboxInputProps) {
|
||||
return (
|
||||
<div className="property-row">
|
||||
<label>{label}</label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={e => onChange(e.target.checked)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SelectInputProps {
|
||||
label: string;
|
||||
value: string;
|
||||
options: { value: string; label: string }[];
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
function SelectInput({ label, value, options, onChange }: SelectInputProps) {
|
||||
return (
|
||||
<div className="property-row">
|
||||
<label>{label}</label>
|
||||
<select
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
style={inputStyle}
|
||||
>
|
||||
{options.map(opt => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ColorInputProps {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
function ColorInput({ label, value, onChange }: ColorInputProps) {
|
||||
return (
|
||||
<div className="property-row">
|
||||
<label>{label}</label>
|
||||
<input
|
||||
type="color"
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
style={{ ...inputStyle, padding: '2px', height: '24px' }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -78,10 +78,14 @@ export const useParticleEditorStore = create<ParticleEditorState>((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<ParticleEditorState>((set) => ({
|
||||
particleData: createDefaultParticleAsset(name),
|
||||
filePath: null,
|
||||
isDirty: true,
|
||||
isPlaying: false,
|
||||
isPlaying: true, // 自动播放 | Auto play
|
||||
selectedPreset: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -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:*",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 引擎集成接口(用于加载纹理)
|
||||
* Engine integration interface (for loading textures)
|
||||
*/
|
||||
export interface IEngineIntegration {
|
||||
loadTextureForComponent(texturePath: string): Promise<number>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 粒子系统上下文
|
||||
* 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'],
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -31,6 +31,21 @@ export interface IParticleModuleConfig {
|
||||
params: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 爆发配置
|
||||
* 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;
|
||||
|
||||
@@ -3,5 +3,6 @@ export {
|
||||
ParticleAssetType,
|
||||
createDefaultParticleAsset,
|
||||
type IParticleAsset,
|
||||
type IParticleModuleConfig
|
||||
type IParticleModuleConfig,
|
||||
type IBurstConfig
|
||||
} from './ParticleLoader';
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<ParticleSystemComponent, string> = new WeakMap();
|
||||
/** 正在加载资产的粒子组件 | Particle components currently loading assets */
|
||||
private _loadingComponents: WeakSet<ParticleSystemComponent> = 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<void> {
|
||||
// 如果有资产 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<void> {
|
||||
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<void> {
|
||||
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) {
|
||||
|
||||
@@ -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<string, IAssetCatalogEntry>(),
|
||||
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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user