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,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';