You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
649 lines
21 KiB
649 lines
21 KiB
/**
|
|
* ParticleEmitter2D - Cocos Creator 3.8.8 自定义2D粒子发射器
|
|
* 模拟引擎内置 ParticleSystem2D 组件的核心功能
|
|
*
|
|
* 功能特性:
|
|
* - 粒子生命周期管理(生成/更新/销毁)
|
|
* - 支持圆形、矩形、边界发射模式
|
|
* - 颜色/大小/透明度/旋转 随生命周期变化
|
|
* - 重力、风力、阻力等物理效果
|
|
* - 纹理动画(Sprite Frame 序列)
|
|
* - 粒子池复用
|
|
*
|
|
* 使用方式:
|
|
* 1. 挂载到任意 Node 上
|
|
* 2. 设置 spriteFrame(粒子纹理)
|
|
* 3. 配置参数或直接调用 play() / stop()
|
|
*/
|
|
|
|
import {
|
|
_decorator,
|
|
Component,
|
|
Node,
|
|
Sprite,
|
|
SpriteFrame,
|
|
UITransform,
|
|
Color,
|
|
Vec2,
|
|
Vec3,
|
|
Quat,
|
|
Layers,
|
|
view,
|
|
size,
|
|
} from 'cc';
|
|
|
|
const { ccclass, property, menu, type, executeInEditMode } = _decorator;
|
|
|
|
// ──────────────────────────────────────────────
|
|
// 数据结构
|
|
// ──────────────────────────────────────────────
|
|
|
|
/** 发射器形状 */
|
|
enum EmitterMode {
|
|
/** 圆形发射(向随机方向) */
|
|
GRAVITY = 0,
|
|
/** 从圆形边缘向中心 / 从中心向外 */
|
|
RADIUS = 1,
|
|
}
|
|
|
|
interface Particle {
|
|
node: Node;
|
|
sprite: Sprite;
|
|
transform: UITransform;
|
|
|
|
// 位置
|
|
pos: Vec2;
|
|
startPos: Vec2;
|
|
|
|
// 速度(重力模式)
|
|
dir: Vec2;
|
|
radialAccel: number;
|
|
tangentialAccel: number;
|
|
|
|
// 速度(半径模式)
|
|
angle: number;
|
|
degreesPerSecond: number;
|
|
radius: number;
|
|
deltaRadius: number;
|
|
|
|
// 大小
|
|
size: number;
|
|
deltaSize: number;
|
|
|
|
// 旋转
|
|
rotation: number;
|
|
deltaRotation: number;
|
|
|
|
// 颜色
|
|
color: Color;
|
|
deltaColor: Color;
|
|
|
|
// 生命周期
|
|
timeToLive: number;
|
|
totalTime: number;
|
|
}
|
|
|
|
// ──────────────────────────────────────────────
|
|
// 组件定义
|
|
// ──────────────────────────────────────────────
|
|
|
|
@ccclass('ParticleEmitter2D')
|
|
@menu('Custom/ParticleEmitter2D')
|
|
@executeInEditMode
|
|
export class ParticleEmitter2D extends Component {
|
|
|
|
// ── 基础设置 ────────────────────────────────
|
|
|
|
@property({ type: SpriteFrame, tooltip: '粒子纹理 SpriteFrame' })
|
|
spriteFrame: SpriteFrame = null;
|
|
|
|
@property({ tooltip: '最大粒子数量', min: 1, max: 10000 })
|
|
maxParticles: number = 150;
|
|
|
|
@property({ tooltip: '自动播放' })
|
|
autoPlay: boolean = true;
|
|
|
|
@property({ tooltip: '循环播放' })
|
|
loop: boolean = true;
|
|
|
|
@property({ tooltip: '是否激活', visible: true })
|
|
active: boolean = true;
|
|
|
|
// ── 发射器设置 ──────────────────────────────
|
|
|
|
@property({ tooltip: '发射器模式: 0=重力 1=半径' })
|
|
emitterMode: EmitterMode = EmitterMode.GRAVITY;
|
|
|
|
@property({ tooltip: '粒子持续时间(秒, -1=永久)' })
|
|
duration: number = -1;
|
|
|
|
@property({ tooltip: '每秒发射粒子数', min: 0 })
|
|
emissionRate: number = 10;
|
|
|
|
// ── 粒子属性(通用) ────────────────────────
|
|
|
|
@property({ tooltip: '粒子生命周期(秒)' })
|
|
particleLifespan: number = 2;
|
|
|
|
@property({ tooltip: '粒子生命周期随机浮动' })
|
|
particleLifespanVariance: number = 0.5;
|
|
|
|
@property({ tooltip: '起始大小' })
|
|
startSize: number = 32;
|
|
|
|
@property({ tooltip: '起始大小浮动' })
|
|
startSizeVariance: number = 5;
|
|
|
|
@property({ tooltip: '结束大小(-1=同起始)' })
|
|
endSize: number = -1;
|
|
|
|
@property({ tooltip: '结束大小浮动' })
|
|
endSizeVariance: number = 0;
|
|
|
|
@property({ tooltip: '起始旋转(度)' })
|
|
startSpin: number = 0;
|
|
|
|
@property({ tooltip: '起始旋转浮动' })
|
|
startSpinVariance: number = 0;
|
|
|
|
@property({ tooltip: '结束旋转(度)' })
|
|
endSpin: number = 0;
|
|
|
|
@property({ tooltip: '结束旋转浮动' })
|
|
endSpinVariance: number = 0;
|
|
|
|
// ── 颜色 ────────────────────────────────────
|
|
|
|
@property({ tooltip: '起始颜色' })
|
|
startColor: Color = new Color(255, 255, 255, 255);
|
|
|
|
@property({ tooltip: '起始颜色浮动' })
|
|
startColorVariance: Color = new Color(0, 0, 0, 0);
|
|
|
|
@property({ tooltip: '结束颜色' })
|
|
endColor: Color = new Color(255, 255, 255, 0);
|
|
|
|
@property({ tooltip: '结束颜色浮动' })
|
|
endColorVariance: Color = new Color(0, 0, 0, 0);
|
|
|
|
// ── 重力模式参数 ────────────────────────────
|
|
|
|
@property({ tooltip: '重力 X' })
|
|
gravityX: number = 0;
|
|
|
|
@property({ tooltip: '重力 Y' })
|
|
gravityY: number = -200;
|
|
|
|
@property({ tooltip: '粒子速度' })
|
|
speed: number = 150;
|
|
|
|
@property({ tooltip: '速度浮动' })
|
|
speedVariance: number = 40;
|
|
|
|
@property({ tooltip: '发射角度(度)' })
|
|
angle: number = 90;
|
|
|
|
@property({ tooltip: '角度浮动(度)' })
|
|
angleVariance: number = 20;
|
|
|
|
@property({ tooltip: '径向加速度' })
|
|
radialAcceleration: number = 0;
|
|
|
|
@property({ tooltip: '径向加速度浮动' })
|
|
radialAccelVariance: number = 0;
|
|
|
|
@property({ tooltip: '切向加速度' })
|
|
tangentialAcceleration: number = 0;
|
|
|
|
@property({ tooltip: '切向加速度浮动' })
|
|
tangentialAccelVariance: number = 0;
|
|
|
|
// ── 半径模式参数 ────────────────────────────
|
|
|
|
@property({ tooltip: '起始半径' })
|
|
startRadius: number = 100;
|
|
|
|
@property({ tooltip: '起始半径浮动' })
|
|
startRadiusVariance: number = 0;
|
|
|
|
@property({ tooltip: '结束半径' })
|
|
endRadius: number = 50;
|
|
|
|
@property({ tooltip: '结束半径浮动' })
|
|
endRadiusVariance: number = 0;
|
|
|
|
@property({ tooltip: '每秒旋转角度' })
|
|
rotatePerSecond: number = 180;
|
|
|
|
@property({ tooltip: '每秒旋转角度浮动' })
|
|
rotatePerSecondVariance: number = 0;
|
|
|
|
// ── 混合模式 ────────────────────────────────
|
|
|
|
@property({ tooltip: 'Blend Src Factor (1=ONE, 770=SRC_ALPHA)' })
|
|
blendSrc: number = 1;
|
|
|
|
@property({ tooltip: 'Blend Dst Factor (1=ONE, 771=ONE_MINUS_SRC_ALPHA)' })
|
|
blendDst: number = 771;
|
|
|
|
// ── 位置相关 ────────────────────────────────
|
|
|
|
@property({ tooltip: '发射器相对于节点的X偏移' })
|
|
sourcePositonX: number = 0;
|
|
|
|
@property({ tooltip: '发射器相对于节点的Y偏移' })
|
|
sourcePositonY: number = 0;
|
|
|
|
@property({ tooltip: '发射器位置浮动X' })
|
|
sourcePositionVarX: number = 0;
|
|
|
|
@property({ tooltip: '发射器位置浮动Y' })
|
|
sourcePositionVarY: number = 0;
|
|
|
|
// ── 内部状态 ────────────────────────────────
|
|
|
|
private _particles: Particle[] = [];
|
|
private _pool: Particle[] = [];
|
|
private _emitCounter: number = 0;
|
|
private _elapsed: number = 0;
|
|
private _isActive: boolean = false;
|
|
private _particleIndex: number = 0;
|
|
|
|
// ── 生命周期 ────────────────────────────────
|
|
|
|
onLoad() {
|
|
// 预创建粒子池
|
|
for (let i = 0; i < this.maxParticles; i++) {
|
|
this._pool.push(this._createParticleNode());
|
|
}
|
|
}
|
|
|
|
onEnable() {
|
|
if (this.autoPlay) {
|
|
this.play();
|
|
}
|
|
}
|
|
|
|
onDisable() {
|
|
this.stop();
|
|
}
|
|
|
|
onDestroy() {
|
|
// 清理所有节点
|
|
for (const p of this._pool) {
|
|
p.node.destroy();
|
|
}
|
|
for (const p of this._particles) {
|
|
p.node.destroy();
|
|
}
|
|
this._pool.length = 0;
|
|
this._particles.length = 0;
|
|
}
|
|
|
|
update(dt: number) {
|
|
if (!this._isActive) return;
|
|
|
|
// 发射新粒子
|
|
this._updateEmission(dt);
|
|
|
|
// 更新现有粒子
|
|
this._updateParticles(dt);
|
|
|
|
// 清理过期粒子
|
|
this._removeDeadParticles();
|
|
}
|
|
|
|
// ── 公共 API ────────────────────────────────
|
|
|
|
/** 开始播放 */
|
|
play() {
|
|
this._isActive = true;
|
|
this._elapsed = 0;
|
|
this._emitCounter = 0;
|
|
this.active = true;
|
|
}
|
|
|
|
/** 停止播放 */
|
|
stop() {
|
|
this._isActive = false;
|
|
this.active = false;
|
|
}
|
|
|
|
/** 停止发射,让现有粒子自然消亡 */
|
|
stopEmitting() {
|
|
this._isActive = false;
|
|
}
|
|
|
|
/** 重置并重新播放 */
|
|
resetSystem() {
|
|
this.removeAllParticles();
|
|
this.play();
|
|
}
|
|
|
|
/** 移除所有粒子 */
|
|
removeAllParticles() {
|
|
for (const p of this._particles) {
|
|
p.node.active = false;
|
|
this._pool.push(p);
|
|
}
|
|
this._particles.length = 0;
|
|
}
|
|
|
|
/** 获取当前活跃粒子数 */
|
|
get particleCount(): number {
|
|
return this._particles.length;
|
|
}
|
|
|
|
/** 是否正在播放 */
|
|
get isPlaying(): boolean {
|
|
return this._isActive;
|
|
}
|
|
|
|
// ── 粒子节点创建 ────────────────────────────
|
|
|
|
private _createParticleNode(): Particle {
|
|
const node = new Node('_particle');
|
|
node.layer = Layers.Enum.UI_2D;
|
|
node.parent = this.node;
|
|
|
|
const sprite = node.addComponent(Sprite);
|
|
const transform = node.getComponent(UITransform) || node.addComponent(UITransform);
|
|
|
|
if (this.spriteFrame) {
|
|
sprite.spriteFrame = this.spriteFrame;
|
|
}
|
|
|
|
// 设置 blend 模式
|
|
sprite.srcBlendFactor = this.blendSrc as any;
|
|
sprite.dstBlendFactor = this.blendDst as any;
|
|
|
|
transform.setAnchorPoint(0.5, 0.5);
|
|
node.active = false;
|
|
|
|
return {
|
|
node,
|
|
sprite,
|
|
transform,
|
|
pos: new Vec2(),
|
|
startPos: new Vec2(),
|
|
dir: new Vec2(),
|
|
radialAccel: 0,
|
|
tangentialAccel: 0,
|
|
angle: 0,
|
|
degreesPerSecond: 0,
|
|
radius: 0,
|
|
deltaRadius: 0,
|
|
size: 1,
|
|
deltaSize: 0,
|
|
rotation: 0,
|
|
deltaRotation: 0,
|
|
color: new Color(),
|
|
deltaColor: new Color(),
|
|
timeToLive: 0,
|
|
totalTime: 0,
|
|
};
|
|
}
|
|
|
|
// ── 发射逻辑 ────────────────────────────────
|
|
|
|
private _updateEmission(dt: number) {
|
|
this._elapsed += dt;
|
|
|
|
// 检查持续时间
|
|
if (this.duration >= 0 && this._elapsed > this.duration) {
|
|
if (this.loop) {
|
|
this.resetSystem();
|
|
return;
|
|
} else if (this._particles.length === 0) {
|
|
this.stop();
|
|
return;
|
|
} else {
|
|
this._isActive = false;
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (this.emissionRate <= 0) return;
|
|
|
|
const rate = 1.0 / this.emissionRate;
|
|
this._emitCounter += dt;
|
|
|
|
while (this._emitCounter >= rate && this._particles.length < this.maxParticles) {
|
|
this._emitParticle();
|
|
this._emitCounter -= rate;
|
|
}
|
|
}
|
|
|
|
private _emitParticle() {
|
|
const p = this._acquireParticle();
|
|
if (!p) return;
|
|
|
|
// 生命周期
|
|
p.totalTime = this._random(this.particleLifespan, this.particleLifespanVariance);
|
|
p.timeToLive = Math.max(0.01, p.totalTime);
|
|
|
|
// 发射器位置 + 浮动
|
|
const sourceX = this._random(this.sourcePositonX, this.sourcePositionVarX);
|
|
const sourceY = this._random(this.sourcePositonY, this.sourcePositionVarY);
|
|
|
|
// 获取节点世界位置
|
|
const worldPos = this.node.getWorldPosition();
|
|
p.pos.set(worldPos.x + sourceX, worldPos.y + sourceY);
|
|
p.startPos.set(p.pos.x, p.pos.y);
|
|
|
|
// 起始大小
|
|
p.size = Math.max(0, this._random(this.startSize, this.startSizeVariance));
|
|
const endSizeVal = this.endSize < 0 ? this.startSize : this.endSize;
|
|
p.deltaSize = (endSizeVal - p.size) / p.totalTime;
|
|
|
|
// 起始旋转
|
|
p.rotation = this._random(this.startSpin, this.startSpinVariance);
|
|
const endRotation = this._random(this.endSpin, this.endSpinVariance);
|
|
p.deltaRotation = (endRotation - p.rotation) / p.totalTime;
|
|
|
|
// 颜色
|
|
p.color.set(
|
|
this._clampColor(this._random(this.startColor.r, this.startColorVariance.r)),
|
|
this._clampColor(this._random(this.startColor.g, this.startColorVariance.g)),
|
|
this._clampColor(this._random(this.startColor.b, this.startColorVariance.b)),
|
|
this._clampColor(this._random(this.startColor.a, this.startColorVariance.a)),
|
|
);
|
|
p.deltaColor.set(
|
|
(this._clampColor(this._random(this.endColor.r, this.endColorVariance.r)) - p.color.r) / p.totalTime,
|
|
(this._clampColor(this._random(this.endColor.g, this.endColorVariance.g)) - p.color.g) / p.totalTime,
|
|
(this._clampColor(this._random(this.endColor.b, this.endColorVariance.b)) - p.color.b) / p.totalTime,
|
|
(this._clampColor(this._random(this.endColor.a, this.endColorVariance.a)) - p.color.a) / p.totalTime,
|
|
);
|
|
|
|
if (this.emitterMode === EmitterMode.GRAVITY) {
|
|
// 重力模式
|
|
const a = this._degToRad(this._random(this.angle, this.angleVariance));
|
|
const s = this._random(this.speed, this.speedVariance);
|
|
p.dir.set(Math.cos(a) * s, Math.sin(a) * s);
|
|
|
|
p.radialAccel = this._random(this.radialAcceleration, this.radialAccelVariance);
|
|
p.tangentialAccel = this._random(this.tangentialAcceleration, this.tangentialAccelVariance);
|
|
} else {
|
|
// 半径模式
|
|
p.angle = this._degToRad(this._random(this.angle, this.angleVariance));
|
|
p.degreesPerSecond = this._degToRad(this._random(this.rotatePerSecond, this.rotatePerSecondVariance));
|
|
p.radius = this._random(this.startRadius, this.startRadiusVariance);
|
|
const endR = this._random(this.endRadius, this.endRadiusVariance);
|
|
p.deltaRadius = (endR - p.radius) / p.totalTime;
|
|
}
|
|
|
|
// 应用初始视觉状态
|
|
this._applyVisuals(p);
|
|
p.node.active = true;
|
|
}
|
|
|
|
// ── 更新逻辑 ────────────────────────────────
|
|
|
|
private _updateParticles(dt: number) {
|
|
const gravity = new Vec2(this.gravityX, this.gravityY);
|
|
|
|
for (let i = 0, len = this._particles.length; i < len; i++) {
|
|
const p = this._particles[i];
|
|
|
|
// 生命周期衰减
|
|
p.timeToLive -= dt;
|
|
if (p.timeToLive <= 0) continue;
|
|
|
|
const t = 1 - (p.timeToLive / p.totalTime); // 0 → 1
|
|
|
|
if (this.emitterMode === EmitterMode.GRAVITY) {
|
|
// 重力模式更新
|
|
|
|
// 径向力(沿粒子→原点方向)
|
|
let radialX = 0, radialY = 0;
|
|
if (p.radialAccel !== 0) {
|
|
const dx = p.pos.x - p.startPos.x;
|
|
const dy = p.pos.y - p.startPos.y;
|
|
const len2 = Math.sqrt(dx * dx + dy * dy) || 1;
|
|
radialX = (dx / len2) * p.radialAccel;
|
|
radialY = (dy / len2) * p.radialAccel;
|
|
}
|
|
|
|
// 切向力(垂直于径向)
|
|
let tangentialX = 0, tangentialY = 0;
|
|
if (p.tangentialAccel !== 0) {
|
|
const dx = p.pos.x - p.startPos.x;
|
|
const dy = p.pos.y - p.startPos.y;
|
|
const len2 = Math.sqrt(dx * dx + dy * dy) || 1;
|
|
tangentialX = (-dy / len2) * p.tangentialAccel;
|
|
tangentialY = (dx / len2) * p.tangentialAccel;
|
|
}
|
|
|
|
// 合力
|
|
const ax = gravity.x + radialX + tangentialX;
|
|
const ay = gravity.y + radialY + tangentialY;
|
|
|
|
p.dir.x += ax * dt;
|
|
p.dir.y += ay * dt;
|
|
|
|
p.pos.x += p.dir.x * dt;
|
|
p.pos.y += p.dir.y * dt;
|
|
} else {
|
|
// 半径模式更新
|
|
p.angle += p.degreesPerSecond * dt;
|
|
p.radius += p.deltaRadius * dt;
|
|
|
|
p.pos.x = p.startPos.x + Math.cos(p.angle) * p.radius;
|
|
p.pos.y = p.startPos.y + Math.sin(p.angle) * p.radius;
|
|
}
|
|
|
|
// 更新大小
|
|
p.size = Math.max(0, p.size + p.deltaSize * dt);
|
|
|
|
// 更新旋转
|
|
p.rotation += p.deltaRotation * dt;
|
|
|
|
// 更新颜色
|
|
p.color.r = this._clampColor(p.color.r + p.deltaColor.r * dt);
|
|
p.color.g = this._clampColor(p.color.g + p.deltaColor.g * dt);
|
|
p.color.b = this._clampColor(p.color.b + p.deltaColor.b * dt);
|
|
p.color.a = this._clampColor(p.color.a + p.deltaColor.a * dt);
|
|
|
|
// 应用视觉
|
|
this._applyVisuals(p);
|
|
}
|
|
}
|
|
|
|
private _applyVisuals(p: Particle) {
|
|
// 位置(从世界坐标转为本地坐标)
|
|
const localPos = this.node.getComponent(UITransform)!
|
|
.convertToNodeSpaceAR(new Vec3(p.pos.x, p.pos.y, 0));
|
|
p.node.setPosition(localPos.x, localPos.y, 0);
|
|
|
|
// 大小
|
|
p.transform.setContentSize(p.size, p.size);
|
|
|
|
// 旋转
|
|
p.node.setRotationFromEuler(0, 0, p.rotation);
|
|
|
|
// 颜色
|
|
p.sprite.color = p.color.clone();
|
|
}
|
|
|
|
// ── 粒子池 ──────────────────────────────────
|
|
|
|
private _acquireParticle(): Particle | null {
|
|
let p: Particle | undefined;
|
|
|
|
// 优先从池中取
|
|
if (this._pool.length > 0) {
|
|
p = this._pool.pop()!;
|
|
} else if (this._particles.length < this.maxParticles) {
|
|
p = this._createParticleNode();
|
|
} else {
|
|
// 回收最老的粒子
|
|
const oldest = this._particles.shift();
|
|
if (oldest) {
|
|
oldest.node.active = false;
|
|
p = oldest;
|
|
}
|
|
}
|
|
|
|
if (p) {
|
|
this._particles.push(p);
|
|
}
|
|
|
|
return p || null;
|
|
}
|
|
|
|
private _removeDeadParticles() {
|
|
for (let i = this._particles.length - 1; i >= 0; i--) {
|
|
if (this._particles[i].timeToLive <= 0) {
|
|
const p = this._particles[i];
|
|
p.node.active = false;
|
|
this._particles.splice(i, 1);
|
|
this._pool.push(p);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── 工具函数 ────────────────────────────────
|
|
|
|
private _random(base: number, variance: number): number {
|
|
return base + variance * (Math.random() * 2 - 1);
|
|
}
|
|
|
|
private _clampColor(v: number): number {
|
|
return Math.max(0, Math.min(255, Math.round(v)));
|
|
}
|
|
|
|
private _degToRad(deg: number): number {
|
|
return deg * Math.PI / 180;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* ──────────────────────────────────────────────
|
|
* 使用说明:
|
|
*
|
|
* 1. 将此脚本挂载到场景中的任意 Node 上(建议是空 Node)
|
|
* 2. 设置 spriteFrame 属性为一张粒子纹理(推荐 64x64 白色圆形 PNG)
|
|
* 3. 在 Inspector 中调整参数:
|
|
* - maxParticles: 最大粒子数
|
|
* - emissionRate: 每秒发射数量
|
|
* - particleLifespan: 粒子存活时间
|
|
* - startSize / endSize: 大小变化
|
|
* - startColor / endColor: 颜色变化
|
|
* - gravityX / gravityY: 重力
|
|
* - speed / angle: 发射速度和角度
|
|
*
|
|
* 4. 代码控制:
|
|
* const emitter = node.getComponent(ParticleEmitter2D);
|
|
* emitter.play();
|
|
* emitter.stop();
|
|
* emitter.stopEmitting(); // 停止发射,现有粒子自然消亡
|
|
* emitter.resetSystem(); // 重置系统
|
|
*
|
|
* 5. 预设效果示例:
|
|
* - 烟雾: gravityY=30, startSize=20, endSize=80, speed=20, startColor=(200,200,200,180), endColor=(200,200,200,0)
|
|
* - 火焰: gravityY=100, startSize=15, endSize=5, speed=80, startColor=(255,200,50,255), endColor=(255,50,0,0)
|
|
* - 星星: emitterMode=RADIUS, startRadius=50, endRadius=50, rotatePerSecond=90, startColor=(255,255,200,255)
|
|
* - 雪花: gravityY=-50, gravityX=20, speed=10, angle=270, startSize=8, endSize=8, angleVariance=30
|
|
* - 爆炸: speed=300, speedVariance=200, angleVariance=360, particleLifespan=0.8, startSize=20, endSize=2
|
|
* ──────────────────────────────────────────────
|
|
*/
|
|
|