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 循环变量
This commit is contained in:
YHH
2025-12-05 23:03:31 +08:00
committed by GitHub
parent 690d7859c8
commit 32d35ef2ee
43 changed files with 9704 additions and 0 deletions

View File

@@ -0,0 +1,232 @@
import type { Particle } from '../Particle';
import type { IParticleModule } from './IParticleModule';
/**
* 动画播放模式
* Animation playback mode
*/
export enum AnimationPlayMode {
/** 单次播放(生命周期内完成一次循环)| Single loop over lifetime */
LifetimeLoop = 'lifetimeLoop',
/** 固定帧率播放 | Fixed frame rate */
FixedFPS = 'fixedFps',
/** 随机选择帧 | Random frame selection */
Random = 'random',
/** 使用速度控制帧 | Speed-based frame */
SpeedBased = 'speedBased',
}
/**
* 动画循环模式
* Animation loop mode
*/
export enum AnimationLoopMode {
/** 不循环(停在最后一帧)| No loop (stop at last frame) */
Once = 'once',
/** 循环播放 | Loop continuously */
Loop = 'loop',
/** 往返循环 | Ping-pong loop */
PingPong = 'pingPong',
}
/**
* 纹理图集动画模块
* Texture sheet animation module
*
* Animates particles through sprite sheet frames.
* 通过精灵图帧动画化粒子。
*/
export class TextureSheetAnimationModule implements IParticleModule {
readonly name = 'TextureSheetAnimation';
enabled = false;
// 图集配置 | Sheet configuration
/** 水平帧数 | Number of columns */
tilesX: number = 1;
/** 垂直帧数 | Number of rows */
tilesY: number = 1;
/** 总帧数0=自动计算为 tilesX * tilesY| Total frames (0 = auto-calculate) */
totalFrames: number = 0;
/** 起始帧 | Start frame index */
startFrame: number = 0;
// 播放配置 | Playback configuration
/** 播放模式 | Playback mode */
playMode: AnimationPlayMode = AnimationPlayMode.LifetimeLoop;
/** 循环模式 | Loop mode */
loopMode: AnimationLoopMode = AnimationLoopMode.Loop;
/** 固定帧率FPS用于 FixedFPS 模式)| Fixed frame rate (for FixedFPS mode) */
frameRate: number = 30;
/** 播放速度乘数 | Playback speed multiplier */
speedMultiplier: number = 1;
/** 循环次数0=无限)| Number of loops (0 = infinite) */
cycleCount: number = 0;
// 内部状态 | Internal state
private _cachedTotalFrames: number = 0;
/**
* 获取实际总帧数
* Get actual total frames
*/
get actualTotalFrames(): number {
return this.totalFrames > 0 ? this.totalFrames : this.tilesX * this.tilesY;
}
/**
* 更新粒子
* Update particle
*
* @param p - 粒子 | Particle
* @param dt - 增量时间 | Delta time
* @param normalizedAge - 归一化年龄 (0-1) | Normalized age
*/
update(p: Particle, dt: number, normalizedAge: number): void {
const frameCount = this.actualTotalFrames;
if (frameCount <= 1) return;
let frameIndex: number;
switch (this.playMode) {
case AnimationPlayMode.LifetimeLoop:
frameIndex = this._getLifetimeFrame(normalizedAge, frameCount);
break;
case AnimationPlayMode.FixedFPS:
frameIndex = this._getFixedFPSFrame(p, dt, frameCount);
break;
case AnimationPlayMode.Random:
frameIndex = this._getRandomFrame(p, frameCount);
break;
case AnimationPlayMode.SpeedBased:
frameIndex = this._getSpeedBasedFrame(p, frameCount);
break;
default:
frameIndex = this.startFrame;
}
// 设置粒子的 UV 坐标 | Set particle UV coordinates
this._setParticleUV(p, frameIndex);
}
/**
* 生命周期帧计算
* Calculate frame based on lifetime
*/
private _getLifetimeFrame(normalizedAge: number, frameCount: number): number {
const progress = normalizedAge * this.speedMultiplier;
return this._applyLoopMode(progress, frameCount);
}
/**
* 固定帧率计算
* Calculate frame based on fixed FPS
*/
private _getFixedFPSFrame(p: Particle, dt: number, frameCount: number): number {
// 使用粒子的 age 来计算当前帧 | Use particle age to calculate current frame
const animTime = p.age * this.frameRate * this.speedMultiplier;
const progress = animTime / frameCount;
return this._applyLoopMode(progress, frameCount);
}
/**
* 随机帧选择(每个粒子使用固定随机帧)
* Random frame selection (each particle uses fixed random frame)
*/
private _getRandomFrame(p: Particle, frameCount: number): number {
// 使用粒子的起始位置作为随机种子(确保一致性)
// Use particle's start position as random seed (for consistency)
const seed = Math.abs(p.startR * 1000 + p.startG * 100 + p.startB * 10) % 1;
return Math.floor(seed * frameCount);
}
/**
* 基于速度的帧计算
* Calculate frame based on particle speed
*/
private _getSpeedBasedFrame(p: Particle, frameCount: number): number {
const speed = Math.sqrt(p.vx * p.vx + p.vy * p.vy);
// 归一化速度(假设最大速度为 500| Normalize speed (assume max 500)
const normalizedSpeed = Math.min(speed / 500, 1);
const frameIndex = Math.floor(normalizedSpeed * (frameCount - 1));
return this.startFrame + frameIndex;
}
/**
* 应用循环模式
* Apply loop mode to progress
*/
private _applyLoopMode(progress: number, frameCount: number): number {
let loopProgress = progress;
switch (this.loopMode) {
case AnimationLoopMode.Once:
// 停在最后一帧 | Stop at last frame
loopProgress = Math.min(progress, 0.9999);
break;
case AnimationLoopMode.Loop:
// 简单循环 | Simple loop
loopProgress = progress % 1;
break;
case AnimationLoopMode.PingPong:
// 往返循环 | Ping-pong
const cycle = Math.floor(progress);
const remainder = progress - cycle;
loopProgress = (cycle % 2 === 0) ? remainder : (1 - remainder);
break;
}
// 检查循环次数限制 | Check cycle count limit
if (this.cycleCount > 0) {
const currentCycle = Math.floor(progress);
if (currentCycle >= this.cycleCount) {
loopProgress = 0.9999; // 停在最后一帧 | Stop at last frame
}
}
// 计算帧索引 | Calculate frame index
const frameIndex = Math.floor(loopProgress * frameCount);
return this.startFrame + Math.min(frameIndex, frameCount - 1);
}
/**
* 设置粒子 UV 坐标
* Set particle UV coordinates
*/
private _setParticleUV(p: Particle, frameIndex: number): void {
// 计算 UV 坐标 | Calculate UV coordinates
const col = frameIndex % this.tilesX;
const row = Math.floor(frameIndex / this.tilesX);
const uWidth = 1 / this.tilesX;
const vHeight = 1 / this.tilesY;
// UV 坐标(左上角为原点)| UV coordinates (top-left origin)
const u0 = col * uWidth;
const v0 = row * vHeight;
const u1 = u0 + uWidth;
const v1 = v0 + vHeight;
// 存储 UV 到粒子的自定义属性
// Store UV in particle's custom properties
// 这里我们使用粒子的 startR/startG/startB 不太合适,需要扩展 Particle
// 暂时通过覆盖 rotation 的高位来存储帧索引(临时方案)
// Temporary: store frame index in a way that can be read by renderer
// The actual UV calculation will be done in the render data provider
(p as any)._animFrame = frameIndex;
(p as any)._animTilesX = this.tilesX;
(p as any)._animTilesY = this.tilesY;
}
}