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:
339
packages/particle/src/ParticleEmitter.ts
Normal file
339
packages/particle/src/ParticleEmitter.ts
Normal file
@@ -0,0 +1,339 @@
|
||||
import type { Particle, ParticlePool } from './Particle';
|
||||
|
||||
/**
|
||||
* 发射形状类型
|
||||
* Emission shape type
|
||||
*/
|
||||
export enum EmissionShape {
|
||||
/** 点发射 | Point emission */
|
||||
Point = 'point',
|
||||
/** 圆形发射(填充)| Circle emission (filled) */
|
||||
Circle = 'circle',
|
||||
/** 矩形发射 | Rectangle emission */
|
||||
Rectangle = 'rectangle',
|
||||
/** 线段发射 | Line emission */
|
||||
Line = 'line',
|
||||
/** 圆锥/扇形发射 | Cone/fan emission */
|
||||
Cone = 'cone',
|
||||
/** 圆环发射(边缘)| Ring emission (edge only) */
|
||||
Ring = 'ring',
|
||||
/** 矩形边缘发射 | Rectangle edge emission */
|
||||
Edge = 'edge'
|
||||
}
|
||||
|
||||
/**
|
||||
* 数值范围
|
||||
* Value range for randomization
|
||||
*/
|
||||
export interface ValueRange {
|
||||
min: number;
|
||||
max: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 颜色值
|
||||
* Color value (RGBA)
|
||||
*/
|
||||
export interface ColorValue {
|
||||
r: number;
|
||||
g: number;
|
||||
b: number;
|
||||
a: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发射器配置
|
||||
* Emitter configuration
|
||||
*/
|
||||
export interface EmitterConfig {
|
||||
/** 每秒发射数量 | Particles per second */
|
||||
emissionRate: number;
|
||||
|
||||
/** 单次爆发数量(0表示持续发射)| Burst count (0 for continuous) */
|
||||
burstCount: number;
|
||||
|
||||
/** 粒子生命时间范围(秒)| Particle lifetime range (seconds) */
|
||||
lifetime: ValueRange;
|
||||
|
||||
/** 发射形状 | Emission shape */
|
||||
shape: EmissionShape;
|
||||
|
||||
/** 形状半径(用于圆形/圆锥)| Shape radius (for circle/cone) */
|
||||
shapeRadius: number;
|
||||
|
||||
/** 形状宽度(用于矩形/线段)| Shape width (for rectangle/line) */
|
||||
shapeWidth: number;
|
||||
|
||||
/** 形状高度(用于矩形)| Shape height (for rectangle) */
|
||||
shapeHeight: number;
|
||||
|
||||
/** 圆锥角度(弧度,用于圆锥发射)| Cone angle (radians, for cone shape) */
|
||||
coneAngle: number;
|
||||
|
||||
/** 发射方向(弧度,0=右)| Emission direction (radians, 0=right) */
|
||||
direction: number;
|
||||
|
||||
/** 发射方向随机范围(弧度)| Direction random spread (radians) */
|
||||
directionSpread: number;
|
||||
|
||||
/** 初始速度范围 | Initial speed range */
|
||||
speed: ValueRange;
|
||||
|
||||
/** 初始角速度范围 | Initial angular velocity range */
|
||||
angularVelocity: ValueRange;
|
||||
|
||||
/** 初始缩放范围 | Initial scale range */
|
||||
startScale: ValueRange;
|
||||
|
||||
/** 初始旋转范围(弧度)| Initial rotation range (radians) */
|
||||
startRotation: ValueRange;
|
||||
|
||||
/** 初始颜色 | Initial color */
|
||||
startColor: ColorValue;
|
||||
|
||||
/** 初始颜色变化范围 | Initial color variance */
|
||||
startColorVariance: ColorValue;
|
||||
|
||||
/** 重力X | Gravity X */
|
||||
gravityX: number;
|
||||
|
||||
/** 重力Y | Gravity Y */
|
||||
gravityY: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建默认发射器配置
|
||||
* Create default emitter configuration
|
||||
*/
|
||||
export function createDefaultEmitterConfig(): EmitterConfig {
|
||||
return {
|
||||
emissionRate: 10,
|
||||
burstCount: 0,
|
||||
lifetime: { min: 1, max: 2 },
|
||||
shape: EmissionShape.Point,
|
||||
shapeRadius: 0,
|
||||
shapeWidth: 0,
|
||||
shapeHeight: 0,
|
||||
coneAngle: Math.PI / 6,
|
||||
direction: -Math.PI / 2,
|
||||
directionSpread: 0,
|
||||
speed: { min: 50, max: 100 },
|
||||
angularVelocity: { min: 0, max: 0 },
|
||||
startScale: { min: 1, max: 1 },
|
||||
startRotation: { min: 0, max: 0 },
|
||||
startColor: { r: 1, g: 1, b: 1, a: 1 },
|
||||
startColorVariance: { r: 0, g: 0, b: 0, a: 0 },
|
||||
gravityX: 0,
|
||||
gravityY: 0
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 粒子发射器
|
||||
* Particle emitter - handles particle spawning
|
||||
*/
|
||||
export class ParticleEmitter {
|
||||
public config: EmitterConfig;
|
||||
|
||||
private _emissionAccumulator: number = 0;
|
||||
private _isEmitting: boolean = true;
|
||||
|
||||
constructor(config?: Partial<EmitterConfig>) {
|
||||
this.config = { ...createDefaultEmitterConfig(), ...config };
|
||||
}
|
||||
|
||||
/** 是否正在发射 | Whether emitter is active */
|
||||
get isEmitting(): boolean {
|
||||
return this._isEmitting;
|
||||
}
|
||||
|
||||
set isEmitting(value: boolean) {
|
||||
this._isEmitting = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发射粒子
|
||||
* Emit particles
|
||||
*
|
||||
* @param pool - Particle pool
|
||||
* @param dt - Delta time in seconds
|
||||
* @param worldX - World position X
|
||||
* @param worldY - World position Y
|
||||
* @returns Number of particles emitted
|
||||
*/
|
||||
emit(pool: ParticlePool, dt: number, worldX: number, worldY: number): number {
|
||||
if (!this._isEmitting) return 0;
|
||||
|
||||
let emitted = 0;
|
||||
|
||||
if (this.config.burstCount > 0) {
|
||||
// 爆发模式 | Burst mode
|
||||
for (let i = 0; i < this.config.burstCount; i++) {
|
||||
const p = pool.spawn();
|
||||
if (p) {
|
||||
this._initParticle(p, worldX, worldY);
|
||||
emitted++;
|
||||
}
|
||||
}
|
||||
this._isEmitting = false;
|
||||
} else {
|
||||
// 持续发射 | Continuous emission
|
||||
this._emissionAccumulator += this.config.emissionRate * dt;
|
||||
while (this._emissionAccumulator >= 1) {
|
||||
const p = pool.spawn();
|
||||
if (p) {
|
||||
this._initParticle(p, worldX, worldY);
|
||||
emitted++;
|
||||
}
|
||||
this._emissionAccumulator -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
return emitted;
|
||||
}
|
||||
|
||||
/**
|
||||
* 立即爆发发射
|
||||
* Burst emit immediately
|
||||
*/
|
||||
burst(pool: ParticlePool, count: number, worldX: number, worldY: number): number {
|
||||
let emitted = 0;
|
||||
for (let i = 0; i < count; i++) {
|
||||
const p = pool.spawn();
|
||||
if (p) {
|
||||
this._initParticle(p, worldX, worldY);
|
||||
emitted++;
|
||||
}
|
||||
}
|
||||
return emitted;
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置发射器
|
||||
* Reset emitter
|
||||
*/
|
||||
reset(): void {
|
||||
this._emissionAccumulator = 0;
|
||||
this._isEmitting = true;
|
||||
}
|
||||
|
||||
private _initParticle(p: Particle, worldX: number, worldY: number): void {
|
||||
const config = this.config;
|
||||
|
||||
// 位置 | Position
|
||||
const [ox, oy] = this._getShapeOffset();
|
||||
p.x = worldX + ox;
|
||||
p.y = worldY + oy;
|
||||
|
||||
// 生命时间 | 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);
|
||||
const speed = randomRange(config.speed.min, config.speed.max);
|
||||
p.vx = Math.cos(dir) * speed;
|
||||
p.vy = Math.sin(dir) * speed;
|
||||
|
||||
// 加速度(重力)| Acceleration (gravity)
|
||||
p.ax = config.gravityX;
|
||||
p.ay = config.gravityY;
|
||||
|
||||
// 旋转 | Rotation
|
||||
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;
|
||||
|
||||
// 颜色 | Color
|
||||
p.r = clamp(config.startColor.r + randomRange(-config.startColorVariance.r, config.startColorVariance.r), 0, 1);
|
||||
p.g = clamp(config.startColor.g + randomRange(-config.startColorVariance.g, config.startColorVariance.g), 0, 1);
|
||||
p.b = clamp(config.startColor.b + randomRange(-config.startColorVariance.b, config.startColorVariance.b), 0, 1);
|
||||
p.alpha = clamp(config.startColor.a + randomRange(-config.startColorVariance.a, config.startColorVariance.a), 0, 1);
|
||||
p.startR = p.r;
|
||||
p.startG = p.g;
|
||||
p.startB = p.b;
|
||||
p.startAlpha = p.alpha;
|
||||
}
|
||||
|
||||
private _getShapeOffset(): [number, number] {
|
||||
const config = this.config;
|
||||
|
||||
switch (config.shape) {
|
||||
case EmissionShape.Point:
|
||||
return [0, 0];
|
||||
|
||||
case EmissionShape.Circle: {
|
||||
// 填充圆形 | Filled circle
|
||||
const angle = Math.random() * Math.PI * 2;
|
||||
const radius = Math.random() * config.shapeRadius;
|
||||
return [Math.cos(angle) * radius, Math.sin(angle) * radius];
|
||||
}
|
||||
|
||||
case EmissionShape.Ring: {
|
||||
// 圆环边缘 | Ring edge only
|
||||
const angle = Math.random() * Math.PI * 2;
|
||||
return [Math.cos(angle) * config.shapeRadius, Math.sin(angle) * config.shapeRadius];
|
||||
}
|
||||
|
||||
case EmissionShape.Rectangle: {
|
||||
// 填充矩形 | Filled rectangle
|
||||
const x = randomRange(-config.shapeWidth / 2, config.shapeWidth / 2);
|
||||
const y = randomRange(-config.shapeHeight / 2, config.shapeHeight / 2);
|
||||
return [x, y];
|
||||
}
|
||||
|
||||
case EmissionShape.Edge: {
|
||||
// 矩形边缘 | Rectangle edge only
|
||||
const perimeter = 2 * (config.shapeWidth + config.shapeHeight);
|
||||
const t = Math.random() * perimeter;
|
||||
|
||||
const w = config.shapeWidth;
|
||||
const h = config.shapeHeight;
|
||||
|
||||
if (t < w) {
|
||||
// 上边 | Top edge
|
||||
return [t - w / 2, h / 2];
|
||||
} else if (t < w + h) {
|
||||
// 右边 | Right edge
|
||||
return [w / 2, h / 2 - (t - w)];
|
||||
} else if (t < 2 * w + h) {
|
||||
// 下边 | Bottom edge
|
||||
return [w / 2 - (t - w - h), -h / 2];
|
||||
} else {
|
||||
// 左边 | Left edge
|
||||
return [-w / 2, -h / 2 + (t - 2 * w - h)];
|
||||
}
|
||||
}
|
||||
|
||||
case EmissionShape.Line: {
|
||||
const t = Math.random() - 0.5;
|
||||
const cos = Math.cos(config.direction + Math.PI / 2);
|
||||
const sin = Math.sin(config.direction + Math.PI / 2);
|
||||
return [cos * config.shapeWidth * t, sin * config.shapeWidth * t];
|
||||
}
|
||||
|
||||
case EmissionShape.Cone: {
|
||||
const angle = config.direction + randomRange(-config.coneAngle / 2, config.coneAngle / 2);
|
||||
const radius = Math.random() * config.shapeRadius;
|
||||
return [Math.cos(angle) * radius, Math.sin(angle) * radius];
|
||||
}
|
||||
|
||||
default:
|
||||
return [0, 0];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function randomRange(min: number, max: number): number {
|
||||
return min + Math.random() * (max - min);
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
Reference in New Issue
Block a user