/** * 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 * ────────────────────────────────────────────── */