Files
esengine/packages/particle/src/ParticleSystemComponent.ts
YHH 32d35ef2ee feat(particle): 添加完整粒子系统和粒子编辑器 (#284)
* feat(editor-core): 添加用户系统自动注册功能

- IUserCodeService 新增 registerSystems/unregisterSystems/getRegisteredSystems 方法
- UserCodeService 实现系统检测、实例化和场景注册逻辑
- ServiceRegistry 在预览开始时注册用户系统,停止时移除
- 热更新时自动重新加载用户系统
- 更新 System 脚本模板添加 @ECSSystem 装饰器

* feat(editor-core): 添加编辑器脚本支持(Inspector/Gizmo)

- registerEditorExtensions 实际注册用户 Inspector 和 Gizmo
- 添加 unregisterEditorExtensions 方法
- ServiceRegistry 在项目加载时编译并加载编辑器脚本
- 项目关闭时自动清理编辑器扩展
- 添加 Inspector 和 Gizmo 脚本创建模板

* feat(particle): 添加粒子系统和粒子编辑器

新增两个包:
- @esengine/particle: 粒子系统核心库
- @esengine/particle-editor: 粒子编辑器 UI

粒子系统功能:
- ECS 组件架构,支持播放/暂停/重置控制
- 7种发射形状:点、圆、环、矩形、边缘、线、锥形
- 5个动画模块:颜色渐变、缩放曲线、速度控制、旋转、噪声
- 纹理动画模块支持精灵表动画
- 3种混合模式:Normal、Additive、Multiply
- 11个内置预设:火焰、烟雾、爆炸、雨、雪等
- 对象池优化,支持粒子复用

编辑器功能:
- 实时 Canvas 预览,支持全屏和鼠标跟随
- 点击触发爆发效果(用于测试爆炸类特效)
- 渐变编辑器:可视化颜色关键帧编辑
- 曲线编辑器:支持缩放曲线和缓动函数
- 预设浏览器:快速应用内置预设
- 模块开关:独立启用/禁用各个模块
- Vector2 样式输入(重力 X/Y)

* feat(particle): 完善粒子系统核心功能

1. Burst 定时爆发系统
   - BurstConfig 接口支持时间、数量、循环次数、间隔
   - 运行时自动处理定时爆发
   - 支持无限循环爆发

2. 速度曲线模块 (VelocityOverLifetimeModule)
   - 6种曲线类型:Constant、Linear、EaseIn、EaseOut、EaseInOut、Custom
   - 自定义关键帧曲线支持
   - 附加速度 X/Y
   - 轨道速度和径向速度

3. 碰撞边界模块 (CollisionModule)
   - 矩形和圆形边界类型
   - 3种碰撞行为:Kill、Bounce、Wrap
   - 反弹系数和最小速度阈值
   - 反弹时生命损失

* feat(particle): 添加力场模块、碰撞模块和世界/本地空间支持

- 新增 ForceFieldModule 支持风力、吸引点、漩涡、湍流四种力场类型
- 新增 SimulationSpace 枚举支持世界空间和本地空间切换
- ParticleSystemComponent 集成力场模块和空间模式
- 粒子编辑器添加 Collision 和 ForceField 模块的 UI 编辑支持
- 新增 Vortex、Leaves、Bouncing 三个预设展示新功能
- 编辑器预览实现完整的碰撞和力场效果

* fix(particle): 移除未使用的 transform 循环变量
2025-12-05 23:03:31 +08:00

601 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 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';
/**
* 爆发配置
* 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;
}
/**
* 粒子混合模式
* Particle blend mode
*/
export enum ParticleBlendMode {
/** 正常混合 | Normal blend */
Normal = 'normal',
/** 叠加 | Additive */
Additive = 'additive',
/** 正片叠底 | Multiply */
Multiply = 'multiply'
}
/**
* 模拟空间
* Simulation space
*/
export enum SimulationSpace {
/** 本地空间(粒子跟随发射器)| Local space (particles follow emitter) */
Local = 'local',
/** 世界空间(粒子不跟随发射器)| World space (particles don't follow emitter) */
World = 'world'
}
/**
* 粒子系统组件
* Particle system component
*
* Manages particle emission, simulation, and provides data for rendering.
* 管理粒子发射、模拟,并为渲染提供数据。
*/
@ECSComponent('ParticleSystem')
@Serializable({ version: 1, typeId: 'ParticleSystem' })
export class ParticleSystemComponent extends Component {
// ============= 基础属性 | Basic Properties =============
/** 最大粒子数量 | 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 =============
/** 粒子纹理 | Particle texture */
@Serialize()
@Property({ type: 'asset', label: 'Texture', assetType: 'texture' })
public texture: string = '';
/** 粒子尺寸(像素)| Particle size (pixels) */
@Serialize()
@Property({ type: 'number', label: 'Particle Size', min: 1 })
public particleSize: number = 8;
/** 混合模式 | 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;
/** 排序顺序 | Sorting order */
@Serialize()
@Property({ type: 'integer', label: 'Sorting Order' })
public sortingOrder: number = 0;
// ============= 爆发配置 | Burst Configuration =============
/** 爆发列表 | Burst list */
@Serialize()
public bursts: BurstConfig[] = [];
// ============= 运行时状态 | Runtime State =============
private _pool: ParticlePool | null = null;
private _emitter: ParticleEmitter | null = null;
private _modules: IParticleModule[] = [];
private _isPlaying: boolean = false;
private _elapsedTime: number = 0;
private _needsRebuild: boolean = true;
/** 爆发状态追踪 | Burst state tracking */
private _burstStates: { firedCount: number; lastFireTime: number }[] = [];
/** 上一帧发射器位置(本地空间用)| Last frame emitter position (for local space) */
private _lastEmitterX: number = 0;
private _lastEmitterY: number = 0;
/** 纹理ID运行时| Texture ID (runtime) */
public textureId: number = 0;
/** 是否正在播放 | Whether playing */
get isPlaying(): boolean {
return this._isPlaying;
}
/** 已播放时间 | Elapsed time */
get elapsedTime(): number {
return this._elapsedTime;
}
/** 活跃粒子数 | Active particle count */
get activeParticleCount(): number {
return this._pool?.activeCount ?? 0;
}
/** 粒子池 | Particle pool */
get pool(): ParticlePool | null {
return this._pool;
}
/** 粒子模块列表 | Particle modules */
get modules(): IParticleModule[] {
return this._modules;
}
/**
* 初始化粒子系统
* Initialize particle system
*/
initialize(): void {
this._rebuildIfNeeded();
}
/**
* 播放粒子系统
* Play particle system
*
* @param worldX - Initial world position X for prewarm | 预热时的初始世界坐标X
* @param worldY - Initial world position Y for prewarm | 预热时的初始世界坐标Y
*/
play(worldX: number = 0, worldY: number = 0): 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);
}
}
/**
* 停止粒子系统
* Stop particle system
*/
stop(clearParticles: boolean = false): void {
this._isPlaying = false;
this._emitter!.isEmitting = false;
this._elapsedTime = 0;
// 重置爆发状态 | Reset burst states
this._burstStates = this.bursts.map(() => ({ firedCount: 0, lastFireTime: -Infinity }));
if (clearParticles) {
this._pool?.recycleAll();
}
}
/**
* 暂停粒子系统
* Pause particle system
*/
pause(): void {
this._isPlaying = false;
}
/**
* 立即爆发发射
* Burst emit
*
* @param count - Number of particles to emit | 发射的粒子数量
* @param worldX - World position X | 世界坐标X
* @param worldY - World position Y | 世界坐标Y
*/
burst(count: number, worldX: number = 0, worldY: number = 0): void {
this._rebuildIfNeeded();
if (!this._emitter || !this._pool) return;
this._emitter.burst(this._pool, count, worldX, worldY);
}
/**
* 更新粒子系统
* Update particle system
*
* @param dt - Delta time in seconds | 时间增量(秒)
* @param worldX - World position X for emission | 发射位置世界坐标X
* @param worldY - World position Y for emission | 发射位置世界坐标Y
*/
update(dt: number, worldX: number = 0, worldY: number = 0): void {
if (!this._isPlaying || !this._pool || !this._emitter) return;
const scaledDt = dt * this.playbackSpeed;
this._simulate(scaledDt, worldX, worldY);
this._elapsedTime += scaledDt;
// 检查持续时间 | Check duration
if (!this.looping && this._elapsedTime >= this.duration) {
this._emitter.isEmitting = false;
if (this._pool.activeCount === 0) {
this._isPlaying = false;
}
}
}
/**
* 添加模块
* Add module
*/
addModule<T extends IParticleModule>(module: T): T {
this._modules.push(module);
return module;
}
/**
* 获取模块
* Get module by type
*/
getModule<T extends IParticleModule>(name: string): T | undefined {
return this._modules.find(m => m.name === name) as T | undefined;
}
/**
* 移除模块
* Remove module
*/
removeModule(module: IParticleModule): boolean {
const index = this._modules.indexOf(module);
if (index >= 0) {
this._modules.splice(index, 1);
return true;
}
return false;
}
/**
* 标记需要重建
* Mark for rebuild
*/
markDirty(): void {
this._needsRebuild = true;
}
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);
}
// 解析颜色 | Parse color
const color = this._parseColor(this.startColor);
// 创建发射器配置 | Create emitter config
const config: EmitterConfig = {
...createDefaultEmitterConfig(),
emissionRate: this.emissionRate,
burstCount: 0,
lifetime: { min: this.lifetimeMin, max: this.lifetimeMax },
shape: this.emissionShape,
shapeRadius: this.shapeRadius,
shapeWidth: this.shapeWidth,
shapeHeight: this.shapeHeight,
coneAngle: Math.PI / 6,
direction: this.direction * Math.PI / 180,
directionSpread: this.directionSpread * Math.PI / 180,
speed: { min: this.speedMin, max: this.speedMax },
angularVelocity: { min: 0, max: 0 },
startScale: { min: this.scaleMin, max: this.scaleMax },
startRotation: { min: 0, max: 0 },
startColor: { ...color, a: this.startAlpha },
startColorVariance: { r: 0, g: 0, b: 0, a: 0 },
gravityX: this.gravityX,
gravityY: this.gravityY
};
if (!this._emitter) {
this._emitter = new ParticleEmitter(config);
} else {
this._emitter.config = config;
}
// 设置默认模块 | Setup default modules
if (this._modules.length === 0) {
// 颜色模块(淡出)| Color module (fade out)
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 }
];
this._modules.push(colorModule);
// 缩放模块 | Size module
const sizeModule = new SizeOverLifetimeModule();
sizeModule.startMultiplier = 1;
sizeModule.endMultiplier = this.endScale;
this._modules.push(sizeModule);
}
this._needsRebuild = false;
}
private _simulate(dt: number, worldX: number, worldY: number): 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;
// 发射新粒子 | Emit new particles
this._emitter.emit(this._pool, dt, worldX, worldY);
// 处理定时爆发 | Process timed bursts
this._processBursts(worldX, worldY);
// 查找碰撞模块并更新发射器位置 | 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();
}
// 查找力场模块并更新发射器位置 | 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 particles
this._pool.forEachActive((p) => {
// 本地空间:粒子跟随发射器移动 | 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;
// 应用模块 | Apply modules
const normalizedAge = p.age / p.lifetime;
for (const module of this._modules) {
if (module.enabled) {
module.update(p, dt, normalizedAge);
}
}
// 检查生命周期 | 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;
}
/**
* 处理定时爆发
* Process timed bursts
*/
private _processBursts(worldX: number, worldY: number): void {
if (!this._pool || !this._emitter || this.bursts.length === 0) return;
// 确保爆发状态数组与配置同步 | Ensure burst states array is synced with config
while (this._burstStates.length < this.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];
const state = this._burstStates[i];
// 检查是否已达到循环次数上限 | Check if reached cycle limit
if (burst.cycles > 0 && state.firedCount >= burst.cycles) {
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 if should fire
if (currentTime >= nextFireTime) {
this._emitter.burst(this._pool, burst.count, worldX, worldY);
state.firedCount++;
state.lastFireTime = currentTime;
}
}
}
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 };
}
onDestroy(): void {
this._pool?.recycleAll();
this._pool = null;
this._emitter = null;
this._modules = [];
}
}