Files
esengine/packages/particle/src/modules/TextureSheetAnimationModule.ts

233 lines
7.7 KiB
TypeScript
Raw Normal View History

feat(particle): 添加完整粒子系统和粒子编辑器 (#284) * feat(editor-core): 添加用户系统自动注册功能 - IUserCodeService 新增 registerSystems/unregisterSystems/getRegisteredSystems 方法 - UserCodeService 实现系统检测、实例化和场景注册逻辑 - ServiceRegistry 在预览开始时注册用户系统,停止时移除 - 热更新时自动重新加载用户系统 - 更新 System 脚本模板添加 @ECSSystem 装饰器 * feat(editor-core): 添加编辑器脚本支持(Inspector/Gizmo) - registerEditorExtensions 实际注册用户 Inspector 和 Gizmo - 添加 unregisterEditorExtensions 方法 - ServiceRegistry 在项目加载时编译并加载编辑器脚本 - 项目关闭时自动清理编辑器扩展 - 添加 Inspector 和 Gizmo 脚本创建模板 * feat(particle): 添加粒子系统和粒子编辑器 新增两个包: - @esengine/particle: 粒子系统核心库 - @esengine/particle-editor: 粒子编辑器 UI 粒子系统功能: - ECS 组件架构,支持播放/暂停/重置控制 - 7种发射形状:点、圆、环、矩形、边缘、线、锥形 - 5个动画模块:颜色渐变、缩放曲线、速度控制、旋转、噪声 - 纹理动画模块支持精灵表动画 - 3种混合模式:Normal、Additive、Multiply - 11个内置预设:火焰、烟雾、爆炸、雨、雪等 - 对象池优化,支持粒子复用 编辑器功能: - 实时 Canvas 预览,支持全屏和鼠标跟随 - 点击触发爆发效果(用于测试爆炸类特效) - 渐变编辑器:可视化颜色关键帧编辑 - 曲线编辑器:支持缩放曲线和缓动函数 - 预设浏览器:快速应用内置预设 - 模块开关:独立启用/禁用各个模块 - Vector2 样式输入(重力 X/Y) * feat(particle): 完善粒子系统核心功能 1. Burst 定时爆发系统 - BurstConfig 接口支持时间、数量、循环次数、间隔 - 运行时自动处理定时爆发 - 支持无限循环爆发 2. 速度曲线模块 (VelocityOverLifetimeModule) - 6种曲线类型:Constant、Linear、EaseIn、EaseOut、EaseInOut、Custom - 自定义关键帧曲线支持 - 附加速度 X/Y - 轨道速度和径向速度 3. 碰撞边界模块 (CollisionModule) - 矩形和圆形边界类型 - 3种碰撞行为:Kill、Bounce、Wrap - 反弹系数和最小速度阈值 - 反弹时生命损失 * feat(particle): 添加力场模块、碰撞模块和世界/本地空间支持 - 新增 ForceFieldModule 支持风力、吸引点、漩涡、湍流四种力场类型 - 新增 SimulationSpace 枚举支持世界空间和本地空间切换 - ParticleSystemComponent 集成力场模块和空间模式 - 粒子编辑器添加 Collision 和 ForceField 模块的 UI 编辑支持 - 新增 Vortex、Leaves、Bouncing 三个预设展示新功能 - 编辑器预览实现完整的碰撞和力场效果 * fix(particle): 移除未使用的 transform 循环变量
2025-12-05 23:03:31 +08:00
import 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;
}
}