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,233 @@
/**
* 粒子数据结构
* Particle data structure
*
* Represents a single particle with all its runtime state.
* 表示单个粒子及其所有运行时状态。
*/
export interface Particle {
/** 是否存活 | Whether particle is alive */
alive: boolean;
/** 位置X | Position X */
x: number;
/** 位置Y | Position Y */
y: number;
/** 速度X | Velocity X */
vx: number;
/** 速度Y | Velocity Y */
vy: number;
/** 加速度X | Acceleration X */
ax: number;
/** 加速度Y | Acceleration Y */
ay: number;
/** 旋转角度(弧度)| Rotation (radians) */
rotation: number;
/** 角速度 | Angular velocity */
angularVelocity: number;
/** 缩放X | Scale X */
scaleX: number;
/** 缩放Y | Scale Y */
scaleY: number;
/** 颜色R (0-1) | Color R (0-1) */
r: number;
/** 颜色G (0-1) | Color G (0-1) */
g: number;
/** 颜色B (0-1) | Color B (0-1) */
b: number;
/** 透明度 (0-1) | Alpha (0-1) */
alpha: number;
/** 当前生命时间(秒)| Current lifetime (seconds) */
age: number;
/** 总生命时间(秒)| Total lifetime (seconds) */
lifetime: number;
/** 初始缩放X | Initial scale X */
startScaleX: number;
/** 初始缩放Y | Initial scale Y */
startScaleY: number;
/** 初始颜色R | Initial color R */
startR: number;
/** 初始颜色G | Initial color G */
startG: number;
/** 初始颜色B | Initial color B */
startB: number;
/** 初始透明度 | Initial alpha */
startAlpha: number;
/** 自定义数据槽 | Custom data slot */
userData?: any;
}
/**
* 创建新粒子
* Create a new particle
*/
export function createParticle(): Particle {
return {
alive: false,
x: 0,
y: 0,
vx: 0,
vy: 0,
ax: 0,
ay: 0,
rotation: 0,
angularVelocity: 0,
scaleX: 1,
scaleY: 1,
r: 1,
g: 1,
b: 1,
alpha: 1,
age: 0,
lifetime: 1,
startScaleX: 1,
startScaleY: 1,
startR: 1,
startG: 1,
startB: 1,
startAlpha: 1
};
}
/**
* 重置粒子状态
* Reset particle state
*/
export function resetParticle(p: Particle): void {
p.alive = false;
p.x = 0;
p.y = 0;
p.vx = 0;
p.vy = 0;
p.ax = 0;
p.ay = 0;
p.rotation = 0;
p.angularVelocity = 0;
p.scaleX = 1;
p.scaleY = 1;
p.r = 1;
p.g = 1;
p.b = 1;
p.alpha = 1;
p.age = 0;
p.lifetime = 1;
p.startScaleX = 1;
p.startScaleY = 1;
p.startR = 1;
p.startG = 1;
p.startB = 1;
p.startAlpha = 1;
p.userData = undefined;
}
/**
* 粒子池
* Particle pool for efficient memory management
*/
export class ParticlePool {
private _particles: Particle[] = [];
private _capacity: number;
private _activeCount: number = 0;
constructor(capacity: number) {
this._capacity = capacity;
for (let i = 0; i < capacity; i++) {
this._particles.push(createParticle());
}
}
/** 池容量 | Pool capacity */
get capacity(): number {
return this._capacity;
}
/** 活跃粒子数 | Active particle count */
get activeCount(): number {
return this._activeCount;
}
/** 所有粒子(包括不活跃的)| All particles (including inactive) */
get particles(): readonly Particle[] {
return this._particles;
}
/**
* 获取一个空闲粒子
* Get an inactive particle
*/
spawn(): Particle | null {
for (const p of this._particles) {
if (!p.alive) {
p.alive = true;
this._activeCount++;
return p;
}
}
return null;
}
/**
* 回收粒子
* Recycle a particle
*/
recycle(p: Particle): void {
if (p.alive) {
p.alive = false;
this._activeCount--;
}
}
/**
* 回收所有粒子
* Recycle all particles
*/
recycleAll(): void {
for (const p of this._particles) {
p.alive = false;
}
this._activeCount = 0;
}
/**
* 遍历活跃粒子
* Iterate over active particles
*/
forEachActive(callback: (p: Particle, index: number) => void): void {
let index = 0;
for (const p of this._particles) {
if (p.alive) {
callback(p, index++);
}
}
}
/**
* 调整池大小
* Resize the pool
*/
resize(newCapacity: number): void {
if (newCapacity > this._capacity) {
for (let i = this._capacity; i < newCapacity; i++) {
this._particles.push(createParticle());
}
} else if (newCapacity < this._capacity) {
// 回收超出容量的活跃粒子 | Recycle active particles beyond capacity
for (let i = newCapacity; i < this._capacity; i++) {
if (this._particles[i].alive) {
this._activeCount--;
}
}
this._particles.length = newCapacity;
}
this._capacity = newCapacity;
}
}

View 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));
}

View File

@@ -0,0 +1,87 @@
import type { ComponentRegistry as ComponentRegistryType, IScene } from '@esengine/ecs-framework';
import type { IRuntimeModule, IPlugin, ModuleManifest, SystemContext } from '@esengine/engine-core';
import { ParticleSystemComponent } from './ParticleSystemComponent';
import { ParticleUpdateSystem } from './systems/ParticleSystem';
export type { SystemContext, ModuleManifest, IRuntimeModule as IRuntimeModuleLoader, IPlugin as IPluginLoader };
/**
* 粒子系统上下文
* Particle system context
*/
export interface ParticleSystemContext extends SystemContext {
particleUpdateSystem?: ParticleUpdateSystem;
/** Transform 组件类型 | Transform component type */
transformType?: new (...args: any[]) => any;
/** 渲染系统(用于注册渲染数据提供者)| Render system (for registering render data provider) */
renderSystem?: {
addRenderDataProvider(provider: any): void;
removeRenderDataProvider(provider: any): void;
};
}
class ParticleRuntimeModule implements IRuntimeModule {
private _updateSystem: ParticleUpdateSystem | null = null;
registerComponents(registry: typeof ComponentRegistryType): void {
registry.register(ParticleSystemComponent);
}
createSystems(scene: IScene, context: SystemContext): void {
const particleContext = context as ParticleSystemContext;
this._updateSystem = new ParticleUpdateSystem();
// 设置 Transform 组件类型 | Set Transform component type
if (particleContext.transformType) {
this._updateSystem.setTransformType(particleContext.transformType);
}
// 在编辑器中禁用系统(手动控制)| Disable in editor (manual control)
if (context.isEditor) {
this._updateSystem.enabled = false;
}
scene.addSystem(this._updateSystem);
particleContext.particleUpdateSystem = this._updateSystem;
// 注册渲染数据提供者 | Register render data provider
if (particleContext.renderSystem) {
const renderDataProvider = this._updateSystem.getRenderDataProvider();
particleContext.renderSystem.addRenderDataProvider(renderDataProvider);
}
}
/**
* 获取粒子更新系统
* Get particle update system
*/
get updateSystem(): ParticleUpdateSystem | null {
return this._updateSystem;
}
}
const manifest: ModuleManifest = {
id: 'particle',
name: '@esengine/particle',
displayName: 'Particle System',
version: '1.0.0',
description: 'Particle system for 2D effects',
category: 'Rendering',
icon: 'Sparkles',
isCore: false,
defaultEnabled: false,
isEngineModule: true,
canContainContent: true,
dependencies: ['core', 'math', 'sprite'],
exports: { components: ['ParticleSystemComponent'] },
editorPackage: '@esengine/particle-editor',
requiresWasm: false
};
export const ParticlePlugin: IPlugin = {
manifest,
runtimeModule: new ParticleRuntimeModule()
};
export { ParticleRuntimeModule };

View File

@@ -0,0 +1,600 @@
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 = [];
}
}

View File

@@ -0,0 +1,89 @@
// Core types
export { type Particle, createParticle, resetParticle, ParticlePool } from './Particle';
// Emitter
export {
ParticleEmitter,
EmissionShape,
createDefaultEmitterConfig,
type EmitterConfig,
type ValueRange,
type ColorValue
} from './ParticleEmitter';
// Component
export { ParticleSystemComponent, ParticleBlendMode, SimulationSpace, type BurstConfig } from './ParticleSystemComponent';
// System
export { ParticleUpdateSystem } from './systems/ParticleSystem';
// Modules
export {
type IParticleModule,
ColorOverLifetimeModule,
type ColorKey,
SizeOverLifetimeModule,
ScaleCurveType,
type ScaleKey,
VelocityOverLifetimeModule,
VelocityCurveType,
type VelocityKey,
RotationOverLifetimeModule,
NoiseModule,
valueNoise2D,
noiseHash,
TextureSheetAnimationModule,
AnimationPlayMode,
AnimationLoopMode,
CollisionModule,
BoundaryType,
CollisionBehavior,
ForceFieldModule,
ForceFieldType,
createDefaultForceField,
type ForceField
} from './modules';
// Rendering
export {
ParticleRenderDataProvider,
type ParticleProviderRenderData,
type IRenderDataProvider
} from './rendering';
// Presets
export {
type ParticlePreset,
PresetCategory,
AllPresets,
DefaultPreset,
FirePreset,
SmokePreset,
SparklePreset,
ExplosionPreset,
RainPreset,
SnowPreset,
MagicAuraPreset,
DustPreset,
BubblePreset,
StarTrailPreset,
VortexPreset,
LeavesPreset,
BouncingPreset,
getPresetsByCategory,
getPresetNames,
getPresetByName,
} from './presets';
// Loaders
export {
ParticleLoader,
ParticleAssetType,
createDefaultParticleAsset,
type IParticleAsset,
type IParticleModuleConfig
} from './loaders';
// Plugin
export { ParticleRuntimeModule, ParticlePlugin } from './ParticleRuntimeModule';
export type { ParticleSystemContext } from './ParticleRuntimeModule';

View File

@@ -0,0 +1,211 @@
/**
* 粒子效果资源加载器
* Particle effect asset loader
*/
import type {
IAssetLoader,
IAssetContent,
IAssetParseContext,
AssetContentType
} from '@esengine/asset-system';
import { EmissionShape, type ColorValue } from '../ParticleEmitter';
import { ParticleBlendMode } from '../ParticleSystemComponent';
/**
* 粒子资产类型常量
* Particle asset type constant
*/
export const ParticleAssetType = 'particle';
/**
* 粒子模块配置
* Particle module configuration
*/
export interface IParticleModuleConfig {
/** 模块类型 | Module type */
type: string;
/** 是否启用 | Enabled */
enabled: boolean;
/** 模块参数 | Module parameters */
params: Record<string, unknown>;
}
/**
* 粒子效果资源数据接口
* Particle effect asset data interface
*/
export interface IParticleAsset {
/** 资源版本 | Asset version */
version: number;
/** 效果名称 | Effect name */
name: string;
/** 效果描述 | Effect description */
description?: string;
// 基础属性 | Basic properties
/** 最大粒子数 | Maximum particles */
maxParticles: number;
/** 是否循环 | Looping */
looping: boolean;
/** 持续时间 | Duration in seconds */
duration: number;
/** 播放速度 | Playback speed */
playbackSpeed: number;
/** 启动时自动播放 | Auto play on start */
playOnAwake: boolean;
// 发射属性 | Emission properties
/** 发射速率 | Emission rate */
emissionRate: number;
/** 发射形状 | Emission shape */
emissionShape: EmissionShape;
/** 形状半径 | Shape radius */
shapeRadius: number;
/** 形状宽度 | Shape width */
shapeWidth: number;
/** 形状高度 | Shape height */
shapeHeight: number;
/** 圆锥角度 | Cone angle */
shapeAngle: number;
// 粒子属性 | Particle properties
/** 生命时间最小值 | Lifetime min */
lifetimeMin: number;
/** 生命时间最大值 | Lifetime max */
lifetimeMax: number;
/** 速度最小值 | Speed min */
speedMin: number;
/** 速度最大值 | Speed max */
speedMax: number;
/** 发射方向(度数)| Direction in degrees */
direction: number;
/** 方向扩散(度数)| Direction spread in degrees */
directionSpread: number;
/** 缩放最小值 | Scale min */
scaleMin: number;
/** 缩放最大值 | Scale max */
scaleMax: number;
/** 重力 X | Gravity X */
gravityX: number;
/** 重力 Y | Gravity Y */
gravityY: number;
// 颜色属性 | Color properties
/** 起始颜色 | Start color */
startColor: ColorValue;
/** 起始透明度 | Start alpha */
startAlpha: number;
/** 结束透明度 | End alpha */
endAlpha: number;
/** 结束缩放 | End scale */
endScale: number;
// 渲染属性 | Rendering properties
/** 粒子大小 | Particle size */
particleSize: number;
/** 混合模式 | Blend mode */
blendMode: ParticleBlendMode;
/** 排序顺序 | Sorting order */
sortingOrder: number;
/** 纹理路径 | Texture path */
texture?: string;
// 模块配置 | Module configurations
/** 模块列表 | Module list */
modules?: IParticleModuleConfig[];
// 纹理动画(可选)| Texture animation (optional)
/** 纹理图集列数 | Texture sheet columns */
textureTilesX?: number;
/** 纹理图集行数 | Texture sheet rows */
textureTilesY?: number;
/** 动画帧率 | Animation frame rate */
textureAnimationFPS?: number;
}
/**
* 创建默认粒子资源数据
* Create default particle asset data
*/
export function createDefaultParticleAsset(name: string = 'New Particle'): IParticleAsset {
return {
version: 1,
name,
description: '',
maxParticles: 100,
looping: true,
duration: 5,
playbackSpeed: 1,
playOnAwake: true,
emissionRate: 10,
emissionShape: EmissionShape.Point,
shapeRadius: 0,
shapeWidth: 0,
shapeHeight: 0,
shapeAngle: 30,
lifetimeMin: 1,
lifetimeMax: 2,
speedMin: 50,
speedMax: 100,
direction: 90,
directionSpread: 30,
scaleMin: 1,
scaleMax: 1,
gravityX: 0,
gravityY: 0,
startColor: { r: 1, g: 1, b: 1, a: 1 },
startAlpha: 1,
endAlpha: 0,
endScale: 1,
particleSize: 8,
blendMode: ParticleBlendMode.Normal,
sortingOrder: 0,
modules: [],
};
}
/**
* 粒子效果加载器实现
* Particle effect loader implementation
*/
export class ParticleLoader implements IAssetLoader<IParticleAsset> {
readonly supportedType = ParticleAssetType;
readonly supportedExtensions = ['.particle', '.particle.json'];
readonly contentType: AssetContentType = 'text';
/**
* 从文本内容解析粒子资源
* Parse particle asset from text content
*/
async parse(content: IAssetContent, _context: IAssetParseContext): Promise<IParticleAsset> {
if (!content.text) {
throw new Error('Particle content is empty');
}
const jsonData = JSON.parse(content.text) as IParticleAsset;
// 验证必要字段 | Validate required fields
if (jsonData.maxParticles === undefined) {
throw new Error('Invalid particle format: missing maxParticles');
}
// 填充默认值 | Fill default values
const defaults = createDefaultParticleAsset();
return { ...defaults, ...jsonData };
}
/**
* 释放已加载的资源
* Dispose loaded asset
*/
dispose(asset: IParticleAsset): void {
(asset as any).modules = null;
}
}

View File

@@ -0,0 +1,7 @@
export {
ParticleLoader,
ParticleAssetType,
createDefaultParticleAsset,
type IParticleAsset,
type IParticleModuleConfig
} from './ParticleLoader';

View File

@@ -0,0 +1,222 @@
import type { Particle } from '../Particle';
import type { IParticleModule } from './IParticleModule';
/**
* 边界类型
* Boundary type
*/
export enum BoundaryType {
/** 无边界 | No boundary */
None = 'none',
/** 矩形边界 | Rectangle boundary */
Rectangle = 'rectangle',
/** 圆形边界 | Circle boundary */
Circle = 'circle'
}
/**
* 碰撞行为
* Collision behavior
*/
export enum CollisionBehavior {
/** 销毁粒子 | Kill particle */
Kill = 'kill',
/** 反弹 | Bounce */
Bounce = 'bounce',
/** 环绕(从另一边出现)| Wrap (appear on opposite side) */
Wrap = 'wrap'
}
/**
* 碰撞模块
* Collision module for particle boundaries
*/
export class CollisionModule implements IParticleModule {
readonly name = 'Collision';
enabled = true;
// ============= 边界设置 | Boundary Settings =============
/** 边界类型 | Boundary type */
boundaryType: BoundaryType = BoundaryType.Rectangle;
/** 碰撞行为 | Collision behavior */
behavior: CollisionBehavior = CollisionBehavior.Kill;
// ============= 矩形边界 | Rectangle Boundary =============
/** 左边界(相对于发射器)| Left boundary (relative to emitter) */
left: number = -200;
/** 右边界(相对于发射器)| Right boundary (relative to emitter) */
right: number = 200;
/** 上边界(相对于发射器)| Top boundary (relative to emitter) */
top: number = -200;
/** 下边界(相对于发射器)| Bottom boundary (relative to emitter) */
bottom: number = 200;
// ============= 圆形边界 | Circle Boundary =============
/** 圆形边界半径 | Circle boundary radius */
radius: number = 200;
// ============= 反弹设置 | Bounce Settings =============
/** 反弹系数 (0-1)1 = 完全弹性 | Bounce factor (0-1), 1 = fully elastic */
bounceFactor: number = 0.8;
/** 最小速度阈值(低于此速度时销毁)| Min velocity threshold (kill if below) */
minVelocityThreshold: number = 5;
/** 反弹时的生命损失 (0-1) | Life loss on bounce (0-1) */
lifeLossOnBounce: number = 0;
// ============= 发射器位置(运行时设置)| Emitter Position (set at runtime) =============
/** 发射器 X 坐标 | Emitter X position */
emitterX: number = 0;
/** 发射器 Y 坐标 | Emitter Y position */
emitterY: number = 0;
/** 粒子死亡标记数组 | Particle death flag array */
private _particlesToKill: Set<Particle> = new Set();
/**
* 获取需要销毁的粒子
* Get particles to kill
*/
getParticlesToKill(): Set<Particle> {
return this._particlesToKill;
}
/**
* 清除死亡标记
* Clear death flags
*/
clearDeathFlags(): void {
this._particlesToKill.clear();
}
update(p: Particle, _dt: number, _normalizedAge: number): void {
if (this.boundaryType === BoundaryType.None) return;
// 计算相对于发射器的位置 | Calculate position relative to emitter
const relX = p.x - this.emitterX;
const relY = p.y - this.emitterY;
let collision = false;
let normalX = 0;
let normalY = 0;
if (this.boundaryType === BoundaryType.Rectangle) {
// 矩形边界检测 | Rectangle boundary detection
if (relX < this.left) {
collision = true;
normalX = 1;
if (this.behavior === CollisionBehavior.Wrap) {
p.x = this.emitterX + this.right;
} else if (this.behavior === CollisionBehavior.Bounce) {
p.x = this.emitterX + this.left;
}
} else if (relX > this.right) {
collision = true;
normalX = -1;
if (this.behavior === CollisionBehavior.Wrap) {
p.x = this.emitterX + this.left;
} else if (this.behavior === CollisionBehavior.Bounce) {
p.x = this.emitterX + this.right;
}
}
if (relY < this.top) {
collision = true;
normalY = 1;
if (this.behavior === CollisionBehavior.Wrap) {
p.y = this.emitterY + this.bottom;
} else if (this.behavior === CollisionBehavior.Bounce) {
p.y = this.emitterY + this.top;
}
} else if (relY > this.bottom) {
collision = true;
normalY = -1;
if (this.behavior === CollisionBehavior.Wrap) {
p.y = this.emitterY + this.top;
} else if (this.behavior === CollisionBehavior.Bounce) {
p.y = this.emitterY + this.bottom;
}
}
} else if (this.boundaryType === BoundaryType.Circle) {
// 圆形边界检测 | Circle boundary detection
const dist = Math.sqrt(relX * relX + relY * relY);
if (dist > this.radius) {
collision = true;
if (dist > 0.001) {
normalX = -relX / dist;
normalY = -relY / dist;
}
if (this.behavior === CollisionBehavior.Wrap) {
// 移动到对面 | Move to opposite side
p.x = this.emitterX - relX * (this.radius / dist) * 0.9;
p.y = this.emitterY - relY * (this.radius / dist) * 0.9;
} else if (this.behavior === CollisionBehavior.Bounce) {
// 移回边界内 | Move back inside boundary
p.x = this.emitterX + relX * (this.radius / dist) * 0.99;
p.y = this.emitterY + relY * (this.radius / dist) * 0.99;
}
}
}
if (collision) {
switch (this.behavior) {
case CollisionBehavior.Kill:
this._particlesToKill.add(p);
break;
case CollisionBehavior.Bounce:
this._applyBounce(p, normalX, normalY);
break;
case CollisionBehavior.Wrap:
// 位置已经在上面处理 | Position already handled above
break;
}
}
}
/**
* 应用反弹
* Apply bounce effect
*/
private _applyBounce(p: Particle, normalX: number, normalY: number): void {
// 反弹速度计算 | Calculate bounce velocity
if (normalX !== 0) {
p.vx = -p.vx * this.bounceFactor;
}
if (normalY !== 0) {
p.vy = -p.vy * this.bounceFactor;
}
// 更新存储的初始速度(如果有速度曲线模块)| Update stored initial velocity
if ('startVx' in p && normalX !== 0) {
(p as any).startVx = -((p as any).startVx) * this.bounceFactor;
}
if ('startVy' in p && normalY !== 0) {
(p as any).startVy = -((p as any).startVy) * this.bounceFactor;
}
// 应用生命损失 | Apply life loss
if (this.lifeLossOnBounce > 0) {
p.lifetime *= (1 - this.lifeLossOnBounce);
}
// 检查最小速度 | Check minimum velocity
const speed = Math.sqrt(p.vx * p.vx + p.vy * p.vy);
if (speed < this.minVelocityThreshold) {
this._particlesToKill.add(p);
}
}
}

View File

@@ -0,0 +1,63 @@
import type { Particle } from '../Particle';
import type { IParticleModule } from './IParticleModule';
/**
* 颜色关键帧
* Color keyframe
*/
export interface ColorKey {
/** 时间点 (0-1) | Time (0-1) */
time: number;
/** 颜色R (0-1) | Color R (0-1) */
r: number;
/** 颜色G (0-1) | Color G (0-1) */
g: number;
/** 颜色B (0-1) | Color B (0-1) */
b: number;
/** 透明度 (0-1) | Alpha (0-1) */
a: number;
}
/**
* 颜色随生命周期变化模块
* Color over lifetime module
*/
export class ColorOverLifetimeModule implements IParticleModule {
readonly name = 'ColorOverLifetime';
enabled = true;
/** 颜色渐变关键帧 | Color gradient keyframes */
gradient: ColorKey[] = [
{ time: 0, r: 1, g: 1, b: 1, a: 1 },
{ time: 1, r: 1, g: 1, b: 1, a: 0 }
];
update(p: Particle, _dt: number, normalizedAge: number): void {
if (this.gradient.length === 0) return;
// 找到当前时间点的两个关键帧 | Find the two keyframes around current time
let startKey = this.gradient[0];
let endKey = this.gradient[this.gradient.length - 1];
for (let i = 0; i < this.gradient.length - 1; i++) {
if (normalizedAge >= this.gradient[i].time && normalizedAge <= this.gradient[i + 1].time) {
startKey = this.gradient[i];
endKey = this.gradient[i + 1];
break;
}
}
// 在两个关键帧之间插值 | Interpolate between keyframes
const range = endKey.time - startKey.time;
const t = range > 0 ? (normalizedAge - startKey.time) / range : 0;
p.r = p.startR * lerp(startKey.r, endKey.r, t);
p.g = p.startG * lerp(startKey.g, endKey.g, t);
p.b = p.startB * lerp(startKey.b, endKey.b, t);
p.alpha = p.startAlpha * lerp(startKey.a, endKey.a, t);
}
}
function lerp(a: number, b: number, t: number): number {
return a + (b - a) * t;
}

View File

@@ -0,0 +1,305 @@
import type { Particle } from '../Particle';
import type { IParticleModule } from './IParticleModule';
/**
* 力场类型
* Force field type
*/
export enum ForceFieldType {
/** 风力(方向性力)| Wind (directional force) */
Wind = 'wind',
/** 吸引/排斥点 | Attraction/repulsion point */
Point = 'point',
/** 漩涡 | Vortex */
Vortex = 'vortex',
/** 湍流 | Turbulence */
Turbulence = 'turbulence'
}
/**
* 力场配置
* Force field configuration
*/
export interface ForceField {
/** 力场类型 | Force field type */
type: ForceFieldType;
/** 启用 | Enabled */
enabled: boolean;
/** 强度 | Strength */
strength: number;
// 风力参数 | Wind parameters
/** 风向 X | Wind direction X */
directionX?: number;
/** 风向 Y | Wind direction Y */
directionY?: number;
// 点力场参数 | Point force parameters
/** 力场位置 X相对于发射器| Position X (relative to emitter) */
positionX?: number;
/** 力场位置 Y相对于发射器| Position Y (relative to emitter) */
positionY?: number;
/** 影响半径 | Influence radius */
radius?: number;
/** 衰减类型 | Falloff type */
falloff?: 'none' | 'linear' | 'quadratic';
// 漩涡参数 | Vortex parameters
/** 漩涡轴心 X | Vortex center X */
centerX?: number;
/** 漩涡轴心 Y | Vortex center Y */
centerY?: number;
/** 向内拉力 | Inward pull strength */
inwardStrength?: number;
// 湍流参数 | Turbulence parameters
/** 湍流频率 | Turbulence frequency */
frequency?: number;
/** 湍流振幅 | Turbulence amplitude */
amplitude?: number;
}
/**
* 创建默认力场配置
* Create default force field
*/
export function createDefaultForceField(type: ForceFieldType): ForceField {
const base = {
type,
enabled: true,
strength: 100,
};
switch (type) {
case ForceFieldType.Wind:
return { ...base, directionX: 1, directionY: 0 };
case ForceFieldType.Point:
return { ...base, positionX: 0, positionY: 0, radius: 100, falloff: 'linear' as const };
case ForceFieldType.Vortex:
return { ...base, centerX: 0, centerY: 0, inwardStrength: 0 };
case ForceFieldType.Turbulence:
return { ...base, frequency: 1, amplitude: 50 };
default:
return base;
}
}
/**
* 力场模块
* Force field module for applying external forces to particles
*/
export class ForceFieldModule implements IParticleModule {
readonly name = 'ForceField';
enabled = true;
/** 力场列表 | Force field list */
forceFields: ForceField[] = [];
/** 发射器位置(运行时设置)| Emitter position (set at runtime) */
emitterX: number = 0;
emitterY: number = 0;
/** 时间累计(用于湍流)| Time accumulator (for turbulence) */
private _time: number = 0;
/**
* 添加力场
* Add force field
*/
addForceField(field: ForceField): void {
this.forceFields.push(field);
}
/**
* 添加风力
* Add wind force
*/
addWind(directionX: number, directionY: number, strength: number = 100): ForceField {
const field: ForceField = {
type: ForceFieldType.Wind,
enabled: true,
strength,
directionX,
directionY,
};
this.forceFields.push(field);
return field;
}
/**
* 添加吸引/排斥点
* Add attraction/repulsion point
*/
addAttractor(x: number, y: number, strength: number = 100, radius: number = 100): ForceField {
const field: ForceField = {
type: ForceFieldType.Point,
enabled: true,
strength, // 正数=吸引,负数=排斥 | positive=attract, negative=repel
positionX: x,
positionY: y,
radius,
falloff: 'linear',
};
this.forceFields.push(field);
return field;
}
/**
* 添加漩涡
* Add vortex
*/
addVortex(x: number, y: number, strength: number = 100, inwardStrength: number = 0): ForceField {
const field: ForceField = {
type: ForceFieldType.Vortex,
enabled: true,
strength,
centerX: x,
centerY: y,
inwardStrength,
};
this.forceFields.push(field);
return field;
}
/**
* 添加湍流
* Add turbulence
*/
addTurbulence(strength: number = 50, frequency: number = 1): ForceField {
const field: ForceField = {
type: ForceFieldType.Turbulence,
enabled: true,
strength,
frequency,
amplitude: strength,
};
this.forceFields.push(field);
return field;
}
/**
* 清除所有力场
* Clear all force fields
*/
clearForceFields(): void {
this.forceFields = [];
}
update(p: Particle, dt: number, _normalizedAge: number): void {
this._time += dt;
for (const field of this.forceFields) {
if (!field.enabled) continue;
switch (field.type) {
case ForceFieldType.Wind:
this._applyWind(p, field, dt);
break;
case ForceFieldType.Point:
this._applyPointForce(p, field, dt);
break;
case ForceFieldType.Vortex:
this._applyVortex(p, field, dt);
break;
case ForceFieldType.Turbulence:
this._applyTurbulence(p, field, dt);
break;
}
}
}
/**
* 应用风力
* Apply wind force
*/
private _applyWind(p: Particle, field: ForceField, dt: number): void {
const dx = field.directionX ?? 1;
const dy = field.directionY ?? 0;
// 归一化方向 | Normalize direction
const len = Math.sqrt(dx * dx + dy * dy);
if (len > 0.001) {
p.vx += (dx / len) * field.strength * dt;
p.vy += (dy / len) * field.strength * dt;
}
}
/**
* 应用点力场(吸引/排斥)
* Apply point force (attraction/repulsion)
*/
private _applyPointForce(p: Particle, field: ForceField, dt: number): void {
const fieldX = this.emitterX + (field.positionX ?? 0);
const fieldY = this.emitterY + (field.positionY ?? 0);
const radius = field.radius ?? 100;
const dx = fieldX - p.x;
const dy = fieldY - p.y;
const distSq = dx * dx + dy * dy;
const dist = Math.sqrt(distSq);
if (dist < 0.001 || dist > radius) return;
// 计算衰减 | Calculate falloff
let falloffFactor = 1;
switch (field.falloff) {
case 'linear':
falloffFactor = 1 - dist / radius;
break;
case 'quadratic':
falloffFactor = 1 - (distSq / (radius * radius));
break;
// 'none' - no falloff
}
// 归一化方向并应用力 | Normalize direction and apply force
const force = field.strength * falloffFactor * dt;
p.vx += (dx / dist) * force;
p.vy += (dy / dist) * force;
}
/**
* 应用漩涡力
* Apply vortex force
*/
private _applyVortex(p: Particle, field: ForceField, dt: number): void {
const centerX = this.emitterX + (field.centerX ?? 0);
const centerY = this.emitterY + (field.centerY ?? 0);
const dx = p.x - centerX;
const dy = p.y - centerY;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < 0.001) return;
// 切向力(旋转)| Tangential force (rotation)
const tangentX = -dy / dist;
const tangentY = dx / dist;
p.vx += tangentX * field.strength * dt;
p.vy += tangentY * field.strength * dt;
// 向心力(可选)| Centripetal force (optional)
const inward = field.inwardStrength ?? 0;
if (inward !== 0) {
p.vx -= (dx / dist) * inward * dt;
p.vy -= (dy / dist) * inward * dt;
}
}
/**
* 应用湍流
* Apply turbulence
*/
private _applyTurbulence(p: Particle, field: ForceField, dt: number): void {
const freq = field.frequency ?? 1;
const amp = field.amplitude ?? field.strength;
// 使用简单的正弦波噪声 | Use simple sine wave noise
const noiseX = Math.sin(p.x * freq * 0.01 + this._time * freq) *
Math.cos(p.y * freq * 0.013 + this._time * freq * 0.7);
const noiseY = Math.cos(p.x * freq * 0.011 + this._time * freq * 0.8) *
Math.sin(p.y * freq * 0.01 + this._time * freq);
p.vx += noiseX * amp * dt;
p.vy += noiseY * amp * dt;
}
}

View File

@@ -0,0 +1,26 @@
import type { Particle } from '../Particle';
/**
* 粒子模块接口
* Particle module interface
*
* Modules modify particle properties over their lifetime.
* 模块在粒子生命周期内修改粒子属性。
*/
export interface IParticleModule {
/** 模块名称 | Module name */
readonly name: string;
/** 是否启用 | Whether enabled */
enabled: boolean;
/**
* 更新粒子
* Update particle
*
* @param p - Particle to update
* @param dt - Delta time in seconds
* @param normalizedAge - Age / Lifetime (0-1)
*/
update(p: Particle, dt: number, normalizedAge: number): void;
}

View File

@@ -0,0 +1,100 @@
import type { Particle } from '../Particle';
import type { IParticleModule } from './IParticleModule';
/**
* 值噪声哈希函数
* Value noise hash function
*
* 使用经典的整数哈希算法生成伪随机值
* Uses classic integer hash algorithm to generate pseudo-random values
*/
export function noiseHash(x: number, y: number): number {
const n = x + y * 57;
const shifted = (n << 13) ^ n;
return ((shifted * (shifted * shifted * 15731 + 789221) + 1376312589) & 0x7fffffff) / 0x7fffffff;
}
/**
* 2D 值噪声函数
* 2D value noise function
*
* 基于双线性插值的简化值噪声实现,返回 [-1, 1] 范围的值
* Simplified value noise using bilinear interpolation, returns value in [-1, 1] range
*/
export function valueNoise2D(x: number, y: number): number {
const ix = Math.floor(x);
const iy = Math.floor(y);
const fx = x - ix;
const fy = y - iy;
const n00 = noiseHash(ix, iy);
const n10 = noiseHash(ix + 1, iy);
const n01 = noiseHash(ix, iy + 1);
const n11 = noiseHash(ix + 1, iy + 1);
// 双线性插值 | Bilinear interpolation
const nx0 = n00 + (n10 - n00) * fx;
const nx1 = n01 + (n11 - n01) * fx;
return (nx0 + (nx1 - nx0) * fy) * 2 - 1;
}
/**
* 噪声模块 - 添加随机扰动
* Noise module - adds random perturbation
*/
export class NoiseModule implements IParticleModule {
readonly name = 'Noise';
enabled = true;
/** 位置噪声强度 | Position noise strength */
positionAmount: number = 0;
/** 速度噪声强度 | Velocity noise strength */
velocityAmount: number = 0;
/** 旋转噪声强度 | Rotation noise strength */
rotationAmount: number = 0;
/** 缩放噪声强度 | Scale noise strength */
scaleAmount: number = 0;
/** 噪声频率 | Noise frequency */
frequency: number = 1;
/** 噪声滚动速度 | Noise scroll speed */
scrollSpeed: number = 1;
private _time: number = 0;
update(p: Particle, dt: number, _normalizedAge: number): void {
this._time += dt * this.scrollSpeed;
// 基于粒子位置和时间的噪声 | Noise based on particle position and time
const noiseX = valueNoise2D(p.x * this.frequency + this._time, p.y * this.frequency);
const noiseY = valueNoise2D(p.x * this.frequency, p.y * this.frequency + this._time);
// 位置噪声 | Position noise
if (this.positionAmount !== 0) {
p.x += noiseX * this.positionAmount * dt;
p.y += noiseY * this.positionAmount * dt;
}
// 速度噪声 | Velocity noise
if (this.velocityAmount !== 0) {
p.vx += noiseX * this.velocityAmount * dt;
p.vy += noiseY * this.velocityAmount * dt;
}
// 旋转噪声 | Rotation noise
if (this.rotationAmount !== 0) {
p.rotation += noiseX * this.rotationAmount * dt;
}
// 缩放噪声 | Scale noise
if (this.scaleAmount !== 0) {
const scaleDelta = noiseX * this.scaleAmount * dt;
p.scaleX += scaleDelta;
p.scaleY += scaleDelta;
}
}
}

View File

@@ -0,0 +1,40 @@
import type { Particle } from '../Particle';
import type { IParticleModule } from './IParticleModule';
/**
* 旋转随生命周期变化模块
* Rotation over lifetime module
*/
export class RotationOverLifetimeModule implements IParticleModule {
readonly name = 'RotationOverLifetime';
enabled = true;
/** 角速度乘数起点 | Angular velocity multiplier start */
angularVelocityMultiplierStart: number = 1;
/** 角速度乘数终点 | Angular velocity multiplier end */
angularVelocityMultiplierEnd: number = 1;
/** 附加旋转(随生命周期累加的旋转量)| Additional rotation over lifetime */
additionalRotation: number = 0;
update(p: Particle, dt: number, normalizedAge: number): void {
// 应用角速度乘数 | Apply angular velocity multiplier
const multiplier = lerp(
this.angularVelocityMultiplierStart,
this.angularVelocityMultiplierEnd,
normalizedAge
);
p.rotation += p.angularVelocity * multiplier * dt;
// 附加旋转 | Additional rotation
if (this.additionalRotation !== 0) {
p.rotation += this.additionalRotation * dt;
}
}
}
function lerp(a: number, b: number, t: number): number {
return a + (b - a) * t;
}

View File

@@ -0,0 +1,119 @@
import type { Particle } from '../Particle';
import type { IParticleModule } from './IParticleModule';
/**
* 缩放关键帧
* Scale keyframe
*/
export interface ScaleKey {
/** 时间点 (0-1) | Time (0-1) */
time: number;
/** 缩放值 | Scale value */
scale: number;
}
/**
* 缩放曲线类型
* Scale curve type
*/
export enum ScaleCurveType {
/** 线性 | Linear */
Linear = 'linear',
/** 缓入 | Ease in */
EaseIn = 'easeIn',
/** 缓出 | Ease out */
EaseOut = 'easeOut',
/** 缓入缓出 | Ease in out */
EaseInOut = 'easeInOut',
/** 自定义关键帧 | Custom keyframes */
Custom = 'custom'
}
/**
* 缩放随生命周期变化模块
* Size over lifetime module
*/
export class SizeOverLifetimeModule implements IParticleModule {
readonly name = 'SizeOverLifetime';
enabled = true;
/** 曲线类型 | Curve type */
curveType: ScaleCurveType = ScaleCurveType.Linear;
/** 起始缩放乘数 | Start scale multiplier */
startMultiplier: number = 1;
/** 结束缩放乘数 | End scale multiplier */
endMultiplier: number = 0;
/** 自定义关键帧(当 curveType 为 Custom 时使用)| Custom keyframes */
customCurve: ScaleKey[] = [];
/** X/Y 分离缩放 | Separate X/Y scaling */
separateAxes: boolean = false;
/** X轴结束缩放乘数 | End scale multiplier for X axis */
endMultiplierX: number = 0;
/** Y轴结束缩放乘数 | End scale multiplier for Y axis */
endMultiplierY: number = 0;
update(p: Particle, _dt: number, normalizedAge: number): void {
let t: number;
switch (this.curveType) {
case ScaleCurveType.Linear:
t = normalizedAge;
break;
case ScaleCurveType.EaseIn:
t = normalizedAge * normalizedAge;
break;
case ScaleCurveType.EaseOut:
t = 1 - (1 - normalizedAge) * (1 - normalizedAge);
break;
case ScaleCurveType.EaseInOut:
t = normalizedAge < 0.5
? 2 * normalizedAge * normalizedAge
: 1 - Math.pow(-2 * normalizedAge + 2, 2) / 2;
break;
case ScaleCurveType.Custom:
t = this._evaluateCustomCurve(normalizedAge);
break;
default:
t = normalizedAge;
}
if (this.separateAxes) {
p.scaleX = p.startScaleX * lerp(this.startMultiplier, this.endMultiplierX, t);
p.scaleY = p.startScaleY * lerp(this.startMultiplier, this.endMultiplierY, t);
} else {
const scale = lerp(this.startMultiplier, this.endMultiplier, t);
p.scaleX = p.startScaleX * scale;
p.scaleY = p.startScaleY * scale;
}
}
private _evaluateCustomCurve(normalizedAge: number): number {
if (this.customCurve.length === 0) return normalizedAge;
if (this.customCurve.length === 1) return this.customCurve[0].scale;
let startKey = this.customCurve[0];
let endKey = this.customCurve[this.customCurve.length - 1];
for (let i = 0; i < this.customCurve.length - 1; i++) {
if (normalizedAge >= this.customCurve[i].time && normalizedAge <= this.customCurve[i + 1].time) {
startKey = this.customCurve[i];
endKey = this.customCurve[i + 1];
break;
}
}
const range = endKey.time - startKey.time;
const t = range > 0 ? (normalizedAge - startKey.time) / range : 0;
return lerp(startKey.scale, endKey.scale, t);
}
}
function lerp(a: number, b: number, t: number): number {
return a + (b - a) * t;
}

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;
}
}

View File

@@ -0,0 +1,201 @@
import type { Particle } from '../Particle';
import type { IParticleModule } from './IParticleModule';
/**
* 速度关键帧
* Velocity keyframe
*/
export interface VelocityKey {
/** 时间点 (0-1) | Time (0-1) */
time: number;
/** 速度乘数 | Velocity multiplier */
multiplier: number;
}
/**
* 速度曲线类型
* Velocity curve type
*/
export enum VelocityCurveType {
/** 常量(无变化)| Constant (no change) */
Constant = 'constant',
/** 线性 | Linear */
Linear = 'linear',
/** 缓入(先慢后快)| Ease in (slow then fast) */
EaseIn = 'easeIn',
/** 缓出(先快后慢)| Ease out (fast then slow) */
EaseOut = 'easeOut',
/** 缓入缓出 | Ease in out */
EaseInOut = 'easeInOut',
/** 自定义关键帧 | Custom keyframes */
Custom = 'custom'
}
/**
* 速度随生命周期变化模块
* Velocity over lifetime module
*/
export class VelocityOverLifetimeModule implements IParticleModule {
readonly name = 'VelocityOverLifetime';
enabled = true;
// ============= 速度曲线 | Velocity Curve =============
/** 速度曲线类型 | Velocity curve type */
curveType: VelocityCurveType = VelocityCurveType.Constant;
/** 起始速度乘数 | Start velocity multiplier */
startMultiplier: number = 1;
/** 结束速度乘数 | End velocity multiplier */
endMultiplier: number = 1;
/** 自定义关键帧(当 curveType 为 Custom 时使用)| Custom keyframes */
customCurve: VelocityKey[] = [];
// ============= 阻力 | Drag =============
/** 线性阻力 (0-1),每秒速度衰减比例 | Linear drag (0-1), velocity decay per second */
linearDrag: number = 0;
// ============= 额外速度 | Additional Velocity =============
/** 轨道速度(绕发射点旋转)| Orbital velocity (rotation around emitter) */
orbitalVelocity: number = 0;
/** 径向速度(向外/向内扩散)| Radial velocity (expand/contract) */
radialVelocity: number = 0;
/** 附加 X 速度 | Additional X velocity */
additionalVelocityX: number = 0;
/** 附加 Y 速度 | Additional Y velocity */
additionalVelocityY: number = 0;
update(p: Particle, dt: number, normalizedAge: number): void {
// 计算速度乘数 | Calculate velocity multiplier
const multiplier = this._evaluateMultiplier(normalizedAge);
// 应用速度乘数到当前速度 | Apply multiplier to current velocity
// 我们需要存储初始速度来正确应用曲线 | We need to store initial velocity to properly apply curve
if (!('startVx' in p)) {
(p as any).startVx = p.vx;
(p as any).startVy = p.vy;
}
const startVx = (p as any).startVx;
const startVy = (p as any).startVy;
// 应用曲线乘数 | Apply curve multiplier
p.vx = startVx * multiplier;
p.vy = startVy * multiplier;
// 应用阻力(在曲线乘数之后)| Apply drag (after curve multiplier)
if (this.linearDrag > 0) {
const dragFactor = Math.pow(1 - this.linearDrag, dt);
p.vx *= dragFactor;
p.vy *= dragFactor;
// 更新存储的起始速度以反映阻力 | Update stored start velocity to reflect drag
(p as any).startVx *= dragFactor;
(p as any).startVy *= dragFactor;
}
// 附加速度 | Additional velocity
if (this.additionalVelocityX !== 0 || this.additionalVelocityY !== 0) {
p.vx += this.additionalVelocityX * dt;
p.vy += this.additionalVelocityY * dt;
}
// 轨道速度 | Orbital velocity
if (this.orbitalVelocity !== 0) {
const angle = Math.atan2(p.y, p.x) + this.orbitalVelocity * dt;
const dist = Math.sqrt(p.x * p.x + p.y * p.y);
p.x = Math.cos(angle) * dist;
p.y = Math.sin(angle) * dist;
}
// 径向速度 | Radial velocity
if (this.radialVelocity !== 0) {
const dist = Math.sqrt(p.x * p.x + p.y * p.y);
if (dist > 0.001) {
const nx = p.x / dist;
const ny = p.y / dist;
p.vx += nx * this.radialVelocity * dt;
p.vy += ny * this.radialVelocity * dt;
}
}
}
/**
* 计算速度乘数
* Evaluate velocity multiplier
*/
private _evaluateMultiplier(normalizedAge: number): number {
let t: number;
switch (this.curveType) {
case VelocityCurveType.Constant:
return this.startMultiplier;
case VelocityCurveType.Linear:
t = normalizedAge;
break;
case VelocityCurveType.EaseIn:
t = normalizedAge * normalizedAge;
break;
case VelocityCurveType.EaseOut:
t = 1 - (1 - normalizedAge) * (1 - normalizedAge);
break;
case VelocityCurveType.EaseInOut:
t = normalizedAge < 0.5
? 2 * normalizedAge * normalizedAge
: 1 - Math.pow(-2 * normalizedAge + 2, 2) / 2;
break;
case VelocityCurveType.Custom:
return this._evaluateCustomCurve(normalizedAge);
default:
t = normalizedAge;
}
return lerp(this.startMultiplier, this.endMultiplier, t);
}
/**
* 计算自定义曲线值
* Evaluate custom curve value
*/
private _evaluateCustomCurve(normalizedAge: number): number {
if (this.customCurve.length === 0) return this.startMultiplier;
if (this.customCurve.length === 1) return this.customCurve[0].multiplier;
// 在边界外返回边界值 | Return boundary values outside range
if (normalizedAge <= this.customCurve[0].time) {
return this.customCurve[0].multiplier;
}
if (normalizedAge >= this.customCurve[this.customCurve.length - 1].time) {
return this.customCurve[this.customCurve.length - 1].multiplier;
}
// 找到相邻关键帧 | Find adjacent keyframes
for (let i = 0; i < this.customCurve.length - 1; i++) {
const start = this.customCurve[i];
const end = this.customCurve[i + 1];
if (normalizedAge >= start.time && normalizedAge <= end.time) {
const range = end.time - start.time;
const t = range > 0 ? (normalizedAge - start.time) / range : 0;
return lerp(start.multiplier, end.multiplier, t);
}
}
return this.startMultiplier;
}
}
function lerp(a: number, b: number, t: number): number {
return a + (b - a) * t;
}

View File

@@ -0,0 +1,22 @@
export type { IParticleModule } from './IParticleModule';
export { ColorOverLifetimeModule, type ColorKey } from './ColorOverLifetimeModule';
export { SizeOverLifetimeModule, ScaleCurveType, type ScaleKey } from './SizeOverLifetimeModule';
export { VelocityOverLifetimeModule, VelocityCurveType, type VelocityKey } from './VelocityOverLifetimeModule';
export { RotationOverLifetimeModule } from './RotationOverLifetimeModule';
export { NoiseModule, valueNoise2D, noiseHash } from './NoiseModule';
export {
TextureSheetAnimationModule,
AnimationPlayMode,
AnimationLoopMode
} from './TextureSheetAnimationModule';
export {
CollisionModule,
BoundaryType,
CollisionBehavior
} from './CollisionModule';
export {
ForceFieldModule,
ForceFieldType,
createDefaultForceField,
type ForceField
} from './ForceFieldModule';

View File

@@ -0,0 +1,774 @@
/**
* 粒子效果预设
* Particle effect presets
*
* Collection of pre-configured particle system settings.
* 预配置的粒子系统设置集合。
*/
import { EmissionShape, type ColorValue } from '../ParticleEmitter';
import { ParticleBlendMode, SimulationSpace } from '../ParticleSystemComponent';
import { ForceFieldType } from '../modules/ForceFieldModule';
import { BoundaryType, CollisionBehavior } from '../modules/CollisionModule';
/**
* 辅助函数:十六进制转 ColorValue
* Helper: hex to ColorValue
*/
function hexToColor(hex: string, alpha = 1): ColorValue {
const h = hex.replace('#', '');
return {
r: parseInt(h.slice(0, 2), 16) / 255,
g: parseInt(h.slice(2, 4), 16) / 255,
b: parseInt(h.slice(4, 6), 16) / 255,
a: alpha,
};
}
/**
* 预设配置接口
* Preset configuration interface
*/
export interface ParticlePreset {
/** 预设名称 | Preset name */
name: string;
/** 预设描述 | Preset description */
description: string;
/** 预设分类 | Preset category */
category: PresetCategory;
/** 预设图标 | Preset icon */
icon?: string;
// 基础属性 | Basic properties
maxParticles: number;
looping: boolean;
duration: number;
playbackSpeed: number;
// 发射属性 | Emission properties
emissionRate: number;
emissionShape: EmissionShape;
shapeRadius: number;
shapeWidth: number;
shapeHeight: number;
shapeAngle: number;
// 粒子属性 | Particle properties
lifetimeMin: number;
lifetimeMax: number;
speedMin: number;
speedMax: number;
direction: number;
directionSpread: number;
scaleMin: number;
scaleMax: number;
gravityX: number;
gravityY: number;
// 颜色属性 | Color properties
startColor: ColorValue;
endColor?: ColorValue;
startAlpha: number;
endAlpha: number;
endScale: number;
// 渲染属性 | Rendering properties
particleSize: number;
blendMode: ParticleBlendMode;
// 可选模块 | Optional modules
simulationSpace?: SimulationSpace;
forceField?: {
type: ForceFieldType;
strength: number;
directionX?: number;
directionY?: number;
centerX?: number;
centerY?: number;
inwardStrength?: number;
frequency?: number;
};
collision?: {
boundaryType: BoundaryType;
behavior: CollisionBehavior;
radius?: number;
bounceFactor?: number;
};
}
/**
* 预设分类
* Preset category
*/
export enum PresetCategory {
/** 自然效果 | Natural effects */
Nature = 'nature',
/** 魔法效果 | Magic effects */
Magic = 'magic',
/** 爆炸效果 | Explosion effects */
Explosion = 'explosion',
/** 环境效果 | Environment effects */
Environment = 'environment',
/** UI 效果 | UI effects */
UI = 'ui',
/** 基础效果 | Basic effects */
Basic = 'basic',
}
/**
* 火焰预设
* Fire preset
*/
export const FirePreset: ParticlePreset = {
name: 'Fire',
description: 'Realistic fire effect with hot colors',
category: PresetCategory.Nature,
icon: 'Flame',
maxParticles: 200,
looping: true,
duration: 5,
playbackSpeed: 1,
emissionRate: 40,
emissionShape: EmissionShape.Rectangle,
shapeRadius: 20,
shapeWidth: 30,
shapeHeight: 5,
shapeAngle: 30,
lifetimeMin: 0.5,
lifetimeMax: 1.2,
speedMin: 80,
speedMax: 150,
direction: 90,
directionSpread: 25,
scaleMin: 0.8,
scaleMax: 1.2,
gravityX: 0,
gravityY: 50,
startColor: hexToColor('#ff6600'),
endColor: hexToColor('#ff0000'),
startAlpha: 1,
endAlpha: 0,
endScale: 0.3,
particleSize: 16,
blendMode: ParticleBlendMode.Additive,
};
/**
* 烟雾预设
* Smoke preset
*/
export const SmokePreset: ParticlePreset = {
name: 'Smoke',
description: 'Soft rising smoke effect',
category: PresetCategory.Nature,
icon: 'Cloud',
maxParticles: 150,
looping: true,
duration: 5,
playbackSpeed: 1,
emissionRate: 15,
emissionShape: EmissionShape.Circle,
shapeRadius: 15,
shapeWidth: 30,
shapeHeight: 30,
shapeAngle: 30,
lifetimeMin: 2,
lifetimeMax: 4,
speedMin: 20,
speedMax: 50,
direction: 90,
directionSpread: 20,
scaleMin: 0.5,
scaleMax: 1,
gravityX: 10,
gravityY: -5,
startColor: hexToColor('#888888'),
endColor: hexToColor('#cccccc'),
startAlpha: 0.6,
endAlpha: 0,
endScale: 2.5,
particleSize: 32,
blendMode: ParticleBlendMode.Normal,
};
/**
* 火花预设
* Sparkle preset
*/
export const SparklePreset: ParticlePreset = {
name: 'Sparkle',
description: 'Twinkling star-like particles',
category: PresetCategory.Magic,
icon: 'Sparkles',
maxParticles: 100,
looping: true,
duration: 5,
playbackSpeed: 1,
emissionRate: 20,
emissionShape: EmissionShape.Circle,
shapeRadius: 40,
shapeWidth: 40,
shapeHeight: 40,
shapeAngle: 360,
lifetimeMin: 0.3,
lifetimeMax: 0.8,
speedMin: 10,
speedMax: 30,
direction: 90,
directionSpread: 360,
scaleMin: 0.5,
scaleMax: 1.5,
gravityX: 0,
gravityY: -20,
startColor: hexToColor('#ffffff'),
startAlpha: 1,
endAlpha: 0,
endScale: 0,
particleSize: 8,
blendMode: ParticleBlendMode.Additive,
};
/**
* 爆炸预设
* Explosion preset
*/
export const ExplosionPreset: ParticlePreset = {
name: 'Explosion',
description: 'Radial burst explosion effect',
category: PresetCategory.Explosion,
icon: 'Zap',
maxParticles: 300,
looping: false,
duration: 1,
playbackSpeed: 1,
emissionRate: 0, // Burst only
emissionShape: EmissionShape.Point,
shapeRadius: 5,
shapeWidth: 10,
shapeHeight: 10,
shapeAngle: 360,
lifetimeMin: 0.3,
lifetimeMax: 0.8,
speedMin: 200,
speedMax: 400,
direction: 0,
directionSpread: 360,
scaleMin: 0.8,
scaleMax: 1.2,
gravityX: 0,
gravityY: 200,
startColor: hexToColor('#ffaa00'),
endColor: hexToColor('#ff4400'),
startAlpha: 1,
endAlpha: 0,
endScale: 0.2,
particleSize: 12,
blendMode: ParticleBlendMode.Additive,
};
/**
* 雨滴预设
* Rain preset
*/
export const RainPreset: ParticlePreset = {
name: 'Rain',
description: 'Falling rain drops',
category: PresetCategory.Environment,
icon: 'CloudRain',
maxParticles: 500,
looping: true,
duration: 10,
playbackSpeed: 1,
emissionRate: 80,
emissionShape: EmissionShape.Line,
shapeRadius: 200,
shapeWidth: 400,
shapeHeight: 10,
shapeAngle: 0,
lifetimeMin: 0.5,
lifetimeMax: 1,
speedMin: 400,
speedMax: 600,
direction: -80,
directionSpread: 5,
scaleMin: 0.8,
scaleMax: 1,
gravityX: 0,
gravityY: 0,
startColor: hexToColor('#88ccff'),
startAlpha: 0.7,
endAlpha: 0.3,
endScale: 1,
particleSize: 6,
blendMode: ParticleBlendMode.Normal,
};
/**
* 雪花预设
* Snow preset
*/
export const SnowPreset: ParticlePreset = {
name: 'Snow',
description: 'Gently falling snowflakes',
category: PresetCategory.Environment,
icon: 'Snowflake',
maxParticles: 300,
looping: true,
duration: 10,
playbackSpeed: 1,
emissionRate: 30,
emissionShape: EmissionShape.Line,
shapeRadius: 200,
shapeWidth: 400,
shapeHeight: 10,
shapeAngle: 0,
lifetimeMin: 3,
lifetimeMax: 6,
speedMin: 20,
speedMax: 50,
direction: -90,
directionSpread: 30,
scaleMin: 0.3,
scaleMax: 1,
gravityX: 0,
gravityY: 0,
startColor: hexToColor('#ffffff'),
startAlpha: 0.9,
endAlpha: 0.5,
endScale: 1,
particleSize: 8,
blendMode: ParticleBlendMode.Normal,
};
/**
* 魔法光环预设
* Magic aura preset
*/
export const MagicAuraPreset: ParticlePreset = {
name: 'Magic Aura',
description: 'Mystical swirling aura',
category: PresetCategory.Magic,
icon: 'Star',
maxParticles: 100,
looping: true,
duration: 5,
playbackSpeed: 1,
emissionRate: 20,
emissionShape: EmissionShape.Circle,
shapeRadius: 50,
shapeWidth: 50,
shapeHeight: 50,
shapeAngle: 360,
lifetimeMin: 1,
lifetimeMax: 2,
speedMin: 5,
speedMax: 15,
direction: 0,
directionSpread: 360,
scaleMin: 0.5,
scaleMax: 1,
gravityX: 0,
gravityY: -30,
startColor: hexToColor('#aa55ff'),
endColor: hexToColor('#5555ff'),
startAlpha: 0.8,
endAlpha: 0,
endScale: 0.5,
particleSize: 10,
blendMode: ParticleBlendMode.Additive,
};
/**
* 灰尘预设
* Dust preset
*/
export const DustPreset: ParticlePreset = {
name: 'Dust',
description: 'Floating dust particles',
category: PresetCategory.Environment,
icon: 'Wind',
maxParticles: 200,
looping: true,
duration: 10,
playbackSpeed: 1,
emissionRate: 15,
emissionShape: EmissionShape.Rectangle,
shapeRadius: 100,
shapeWidth: 200,
shapeHeight: 150,
shapeAngle: 0,
lifetimeMin: 4,
lifetimeMax: 8,
speedMin: 5,
speedMax: 15,
direction: 45,
directionSpread: 90,
scaleMin: 0.2,
scaleMax: 0.6,
gravityX: 10,
gravityY: -2,
startColor: hexToColor('#ccbb99'),
startAlpha: 0.3,
endAlpha: 0.1,
endScale: 1.2,
particleSize: 4,
blendMode: ParticleBlendMode.Normal,
};
/**
* 泡泡预设
* Bubble preset
*/
export const BubblePreset: ParticlePreset = {
name: 'Bubbles',
description: 'Rising soap bubbles',
category: PresetCategory.Environment,
icon: 'CircleDot',
maxParticles: 50,
looping: true,
duration: 10,
playbackSpeed: 1,
emissionRate: 5,
emissionShape: EmissionShape.Rectangle,
shapeRadius: 40,
shapeWidth: 80,
shapeHeight: 20,
shapeAngle: 0,
lifetimeMin: 2,
lifetimeMax: 4,
speedMin: 30,
speedMax: 60,
direction: 90,
directionSpread: 20,
scaleMin: 0.5,
scaleMax: 1.5,
gravityX: 10,
gravityY: -10,
startColor: hexToColor('#aaddff'),
startAlpha: 0.5,
endAlpha: 0.2,
endScale: 1.3,
particleSize: 16,
blendMode: ParticleBlendMode.Normal,
};
/**
* 星轨预设
* Star trail preset
*/
export const StarTrailPreset: ParticlePreset = {
name: 'Star Trail',
description: 'Glowing star trail effect',
category: PresetCategory.Magic,
icon: 'Sparkle',
maxParticles: 150,
looping: true,
duration: 5,
playbackSpeed: 1,
emissionRate: 50,
emissionShape: EmissionShape.Point,
shapeRadius: 2,
shapeWidth: 4,
shapeHeight: 4,
shapeAngle: 0,
lifetimeMin: 0.2,
lifetimeMax: 0.6,
speedMin: 5,
speedMax: 20,
direction: 180,
directionSpread: 30,
scaleMin: 0.8,
scaleMax: 1.2,
gravityX: 0,
gravityY: 0,
startColor: hexToColor('#ffff88'),
endColor: hexToColor('#ffaa44'),
startAlpha: 1,
endAlpha: 0,
endScale: 0.1,
particleSize: 6,
blendMode: ParticleBlendMode.Additive,
};
/**
* 默认预设(简单)
* Default preset (simple)
*/
export const DefaultPreset: ParticlePreset = {
name: 'Default',
description: 'Basic particle emitter',
category: PresetCategory.Basic,
icon: 'Circle',
maxParticles: 100,
looping: true,
duration: 5,
playbackSpeed: 1,
emissionRate: 10,
emissionShape: EmissionShape.Point,
shapeRadius: 0,
shapeWidth: 0,
shapeHeight: 0,
shapeAngle: 0,
lifetimeMin: 1,
lifetimeMax: 2,
speedMin: 50,
speedMax: 100,
direction: 90,
directionSpread: 30,
scaleMin: 1,
scaleMax: 1,
gravityX: 0,
gravityY: 0,
startColor: hexToColor('#ffffff'),
startAlpha: 1,
endAlpha: 0,
endScale: 1,
particleSize: 8,
blendMode: ParticleBlendMode.Normal,
};
/**
* 漩涡预设
* Vortex preset
*/
export const VortexPreset: ParticlePreset = {
name: 'Vortex',
description: 'Swirling vortex with inward pull',
category: PresetCategory.Magic,
icon: 'Tornado',
maxParticles: 200,
looping: true,
duration: 10,
playbackSpeed: 1,
emissionRate: 30,
emissionShape: EmissionShape.Circle,
shapeRadius: 80,
shapeWidth: 160,
shapeHeight: 160,
shapeAngle: 360,
lifetimeMin: 2,
lifetimeMax: 4,
speedMin: 10,
speedMax: 30,
direction: 0,
directionSpread: 360,
scaleMin: 0.5,
scaleMax: 1,
gravityX: 0,
gravityY: 0,
startColor: hexToColor('#88ccff'),
startAlpha: 0.8,
endAlpha: 0,
endScale: 0.3,
particleSize: 8,
blendMode: ParticleBlendMode.Additive,
forceField: {
type: ForceFieldType.Vortex,
strength: 150,
centerX: 0,
centerY: 0,
inwardStrength: 30,
},
};
/**
* 落叶预设
* Falling leaves preset
*/
export const LeavesPreset: ParticlePreset = {
name: 'Leaves',
description: 'Falling leaves with wind effect',
category: PresetCategory.Environment,
icon: 'Leaf',
maxParticles: 100,
looping: true,
duration: 10,
playbackSpeed: 1,
emissionRate: 8,
emissionShape: EmissionShape.Line,
shapeRadius: 200,
shapeWidth: 400,
shapeHeight: 10,
shapeAngle: 0,
lifetimeMin: 4,
lifetimeMax: 8,
speedMin: 30,
speedMax: 60,
direction: -90,
directionSpread: 20,
scaleMin: 0.6,
scaleMax: 1.2,
gravityX: 0,
gravityY: 20,
startColor: hexToColor('#dd8844'),
startAlpha: 0.9,
endAlpha: 0.6,
endScale: 1,
particleSize: 12,
blendMode: ParticleBlendMode.Normal,
forceField: {
type: ForceFieldType.Turbulence,
strength: 40,
frequency: 0.5,
},
};
/**
* 弹球预设
* Bouncing balls preset
*/
export const BouncingPreset: ParticlePreset = {
name: 'Bouncing',
description: 'Bouncing particles in a box',
category: PresetCategory.Basic,
icon: 'Circle',
maxParticles: 50,
looping: true,
duration: 10,
playbackSpeed: 1,
emissionRate: 5,
emissionShape: EmissionShape.Point,
shapeRadius: 0,
shapeWidth: 0,
shapeHeight: 0,
shapeAngle: 0,
lifetimeMin: 8,
lifetimeMax: 12,
speedMin: 100,
speedMax: 200,
direction: 90,
directionSpread: 60,
scaleMin: 0.8,
scaleMax: 1.2,
gravityX: 0,
gravityY: 200,
startColor: hexToColor('#66aaff'),
startAlpha: 1,
endAlpha: 0.8,
endScale: 1,
particleSize: 16,
blendMode: ParticleBlendMode.Normal,
collision: {
boundaryType: BoundaryType.Rectangle,
behavior: CollisionBehavior.Bounce,
bounceFactor: 0.8,
},
};
/**
* 所有预设
* All presets
*/
export const AllPresets: ParticlePreset[] = [
DefaultPreset,
FirePreset,
SmokePreset,
SparklePreset,
ExplosionPreset,
RainPreset,
SnowPreset,
MagicAuraPreset,
DustPreset,
BubblePreset,
StarTrailPreset,
VortexPreset,
LeavesPreset,
BouncingPreset,
];
/**
* 按分类获取预设
* Get presets by category
*/
export function getPresetsByCategory(category: PresetCategory): ParticlePreset[] {
return AllPresets.filter(p => p.category === category);
}
/**
* 获取预设名称列表
* Get preset name list
*/
export function getPresetNames(): string[] {
return AllPresets.map(p => p.name);
}
/**
* 按名称获取预设
* Get preset by name
*/
export function getPresetByName(name: string): ParticlePreset | undefined {
return AllPresets.find(p => p.name === name);
}

View File

@@ -0,0 +1,212 @@
import type { ParticleSystemComponent } from '../ParticleSystemComponent';
import { Color } from '@esengine/ecs-framework-math';
/**
* 粒子渲染数据(与 EngineRenderSystem 兼容)
* Particle render data (compatible with EngineRenderSystem)
*
* This interface is compatible with ProviderRenderData from EngineRenderSystem.
* 此接口与 EngineRenderSystem 的 ProviderRenderData 兼容。
*/
export interface ParticleProviderRenderData {
transforms: Float32Array;
textureIds: Uint32Array;
uvs: Float32Array;
colors: Uint32Array;
tileCount: number;
sortingOrder: number;
texturePath?: string;
}
/**
* Transform 接口(避免直接依赖 engine-core
* Transform interface (avoid direct dependency on engine-core)
*/
interface ITransformLike {
worldPosition?: { x: number; y: number };
position: { x: number; y: number };
}
/**
* 渲染数据提供者接口(与 EngineRenderSystem 兼容)
* Render data provider interface (compatible with EngineRenderSystem)
*
* This interface matches IRenderDataProvider from @esengine/ecs-engine-bindgen.
* 此接口与 @esengine/ecs-engine-bindgen 的 IRenderDataProvider 匹配。
*/
export interface IRenderDataProvider {
getRenderData(): readonly ParticleProviderRenderData[];
}
/**
* 粒子渲染数据提供者
* Particle render data provider
*
* Collects render data from all active particle systems.
* 从所有活跃的粒子系统收集渲染数据。
*
* Implements IRenderDataProvider for integration with EngineRenderSystem.
* 实现 IRenderDataProvider 以便与 EngineRenderSystem 集成。
*/
export class ParticleRenderDataProvider implements IRenderDataProvider {
private _particleSystems: Map<ParticleSystemComponent, ITransformLike> = new Map();
private _renderDataCache: ParticleProviderRenderData[] = [];
private _dirty: boolean = true;
// 预分配的缓冲区 | Pre-allocated buffers
private _maxParticles: number = 0;
private _transforms: Float32Array = new Float32Array(0);
private _textureIds: Uint32Array = new Uint32Array(0);
private _uvs: Float32Array = new Float32Array(0);
private _colors: Uint32Array = new Uint32Array(0);
/**
* 注册粒子系统
* Register particle system
*/
register(component: ParticleSystemComponent, transform: ITransformLike): void {
this._particleSystems.set(component, transform);
this._dirty = true;
}
/**
* 注销粒子系统
* Unregister particle system
*/
unregister(component: ParticleSystemComponent): void {
this._particleSystems.delete(component);
this._dirty = true;
}
/**
* 标记为脏
* Mark as dirty
*/
markDirty(): void {
this._dirty = true;
}
/**
* 获取渲染数据
* Get render data
*/
getRenderData(): readonly ParticleProviderRenderData[] {
this._updateRenderData();
return this._renderDataCache;
}
private _updateRenderData(): void {
this._renderDataCache.length = 0;
// 计算总粒子数 | Calculate total particle count
let totalParticles = 0;
for (const [component] of this._particleSystems) {
if (component.isPlaying && component.pool) {
totalParticles += component.pool.activeCount;
}
}
if (totalParticles === 0) return;
// 确保缓冲区足够大 | Ensure buffers are large enough
if (totalParticles > this._maxParticles) {
this._maxParticles = Math.max(totalParticles, this._maxParticles * 2, 1000);
this._transforms = new Float32Array(this._maxParticles * 7);
this._textureIds = new Uint32Array(this._maxParticles);
this._uvs = new Float32Array(this._maxParticles * 4);
this._colors = new Uint32Array(this._maxParticles);
}
// 按 sortingOrder 分组 | Group by sortingOrder
const groups = new Map<number, {
component: ParticleSystemComponent;
transform: ITransformLike;
}[]>();
for (const [component, transform] of this._particleSystems) {
if (!component.isPlaying || !component.pool || component.pool.activeCount === 0) {
continue;
}
const order = component.sortingOrder;
if (!groups.has(order)) {
groups.set(order, []);
}
groups.get(order)!.push({ component, transform });
}
// 为每个 sortingOrder 组生成渲染数据 | Generate render data for each sortingOrder group
for (const [sortingOrder, systems] of groups) {
let particleIndex = 0;
for (const { component } of systems) {
const pool = component.pool!;
const size = component.particleSize;
const textureId = component.textureId;
// 世界偏移 | World offset (particles are already in world space after emission)
// 不需要额外偏移,因为粒子发射时已经使用了世界坐标
// No additional offset needed since particles use world coords at emission
pool.forEachActive((p) => {
const tOffset = particleIndex * 7;
const uvOffset = particleIndex * 4;
// Transform: x, y, rotation, scaleX, scaleY, originX, originY
this._transforms[tOffset] = p.x;
this._transforms[tOffset + 1] = p.y;
this._transforms[tOffset + 2] = p.rotation;
this._transforms[tOffset + 3] = size * p.scaleX;
this._transforms[tOffset + 4] = size * p.scaleY;
this._transforms[tOffset + 5] = 0.5; // originX
this._transforms[tOffset + 6] = 0.5; // originY
// Texture ID
this._textureIds[particleIndex] = textureId;
// UV (full texture)
this._uvs[uvOffset] = 0;
this._uvs[uvOffset + 1] = 0;
this._uvs[uvOffset + 2] = 1;
this._uvs[uvOffset + 3] = 1;
// Color (packed ABGR for WebGL)
this._colors[particleIndex] = Color.packABGR(
Math.round(p.r * 255),
Math.round(p.g * 255),
Math.round(p.b * 255),
p.alpha
);
particleIndex++;
});
}
if (particleIndex > 0) {
// 创建当前组的渲染数据 | Create render data for current group
const renderData: ParticleProviderRenderData = {
transforms: this._transforms.subarray(0, particleIndex * 7),
textureIds: this._textureIds.subarray(0, particleIndex),
uvs: this._uvs.subarray(0, particleIndex * 4),
colors: this._colors.subarray(0, particleIndex),
tileCount: particleIndex,
sortingOrder,
texturePath: systems[0]?.component.texture || undefined
};
this._renderDataCache.push(renderData);
}
}
this._dirty = false;
}
/**
* 清理
* Cleanup
*/
dispose(): void {
this._particleSystems.clear();
this._renderDataCache.length = 0;
}
}

View File

@@ -0,0 +1,5 @@
export {
ParticleRenderDataProvider,
type ParticleProviderRenderData,
type IRenderDataProvider
} from './ParticleRenderDataProvider';

View File

@@ -0,0 +1,118 @@
import { EntitySystem, Matcher, ECSSystem, Time, Entity } from '@esengine/ecs-framework';
import { ParticleSystemComponent } from '../ParticleSystemComponent';
import { ParticleRenderDataProvider } from '../rendering/ParticleRenderDataProvider';
/**
* Transform 组件接口(避免直接依赖 engine-core
* Transform component interface (avoid direct dependency on engine-core)
*/
interface ITransformComponent {
worldPosition?: { x: number; y: number; z: number };
position: { x: number; y: number; z: number };
}
/**
* 粒子更新系统
* Particle update system
*
* Updates all ParticleSystemComponents with their entity's world position.
* 使用实体的世界坐标更新所有粒子系统组件。
*/
@ECSSystem('ParticleUpdate', { updateOrder: 100 })
export class ParticleUpdateSystem extends EntitySystem {
private _transformType: (new (...args: any[]) => ITransformComponent) | null = null;
private _renderDataProvider: ParticleRenderDataProvider;
constructor() {
super(Matcher.empty().all(ParticleSystemComponent));
this._renderDataProvider = new ParticleRenderDataProvider();
}
/**
* 设置 Transform 组件类型
* Set Transform component type
*
* @param transformType - Transform component class | Transform 组件类
*/
setTransformType(transformType: new (...args: any[]) => ITransformComponent): void {
this._transformType = transformType;
}
/**
* 获取渲染数据提供者
* Get render data provider
*/
getRenderDataProvider(): ParticleRenderDataProvider {
return this._renderDataProvider;
}
protected override process(entities: readonly Entity[]): void {
const deltaTime = Time.deltaTime;
for (const entity of entities) {
if (!entity.enabled) continue;
const particle = entity.getComponent(ParticleSystemComponent) as ParticleSystemComponent | null;
if (!particle) continue;
let worldX = 0;
let worldY = 0;
let transform: ITransformComponent | null = null;
// 获取 Transform 位置 | Get Transform position
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;
}
}
// 更新粒子系统 | Update particle system
if (particle.isPlaying) {
particle.update(deltaTime, worldX, worldY);
}
// 更新渲染数据提供者的 Transform 引用 | Update render data provider's Transform reference
if (transform) {
this._renderDataProvider.register(particle, transform);
}
}
// 标记渲染数据需要更新 | Mark render data as dirty
this._renderDataProvider.markDirty();
}
protected override onAdded(entity: Entity): void {
const particle = entity.getComponent(ParticleSystemComponent) as ParticleSystemComponent | null;
if (particle) {
particle.initialize();
// 注册到渲染数据提供者 | 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);
}
}
}
}
protected override onRemoved(entity: Entity): void {
const particle = entity.getComponent(ParticleSystemComponent) as ParticleSystemComponent | null;
if (particle) {
// 从渲染数据提供者注销 | Unregister from render data provider
this._renderDataProvider.unregister(particle);
}
}
/**
* 系统销毁时清理
* Cleanup on system destroy
*/
public override destroy(): void {
super.destroy();
this._renderDataProvider.dispose();
}
}