Files
esengine/packages/particle/src/ParticleSystemComponent.ts

601 lines
20 KiB
TypeScript
Raw Normal View History

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
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 = [];
}
}