2025-12-05 23:03:31 +08:00
|
|
|
|
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
|
2025-12-07 01:00:35 +08:00
|
|
|
|
* @param worldRotation - World rotation in radians (applied to emission direction)
|
|
|
|
|
|
* @param worldScaleX - World scale X (applied to emission offset and speed)
|
|
|
|
|
|
* @param worldScaleY - World scale Y (applied to emission offset and speed)
|
2025-12-05 23:03:31 +08:00
|
|
|
|
* @returns Number of particles emitted
|
|
|
|
|
|
*/
|
2025-12-07 01:00:35 +08:00
|
|
|
|
emit(
|
|
|
|
|
|
pool: ParticlePool,
|
|
|
|
|
|
dt: number,
|
|
|
|
|
|
worldX: number,
|
|
|
|
|
|
worldY: number,
|
|
|
|
|
|
worldRotation: number = 0,
|
|
|
|
|
|
worldScaleX: number = 1,
|
|
|
|
|
|
worldScaleY: number = 1
|
|
|
|
|
|
): number {
|
2025-12-05 23:03:31 +08:00
|
|
|
|
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) {
|
2025-12-07 01:00:35 +08:00
|
|
|
|
this._initParticle(p, worldX, worldY, worldRotation, worldScaleX, worldScaleY);
|
2025-12-05 23:03:31 +08:00
|
|
|
|
emitted++;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
this._isEmitting = false;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 持续发射 | Continuous emission
|
|
|
|
|
|
this._emissionAccumulator += this.config.emissionRate * dt;
|
|
|
|
|
|
while (this._emissionAccumulator >= 1) {
|
|
|
|
|
|
const p = pool.spawn();
|
|
|
|
|
|
if (p) {
|
2025-12-07 01:00:35 +08:00
|
|
|
|
this._initParticle(p, worldX, worldY, worldRotation, worldScaleX, worldScaleY);
|
2025-12-05 23:03:31 +08:00
|
|
|
|
emitted++;
|
|
|
|
|
|
}
|
|
|
|
|
|
this._emissionAccumulator -= 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return emitted;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 立即爆发发射
|
|
|
|
|
|
* Burst emit immediately
|
2025-12-07 01:00:35 +08:00
|
|
|
|
*
|
|
|
|
|
|
* @param pool - Particle pool
|
|
|
|
|
|
* @param count - Number of particles to emit
|
|
|
|
|
|
* @param worldX - World position X
|
|
|
|
|
|
* @param worldY - World position Y
|
|
|
|
|
|
* @param worldRotation - World rotation in radians
|
|
|
|
|
|
* @param worldScaleX - World scale X
|
|
|
|
|
|
* @param worldScaleY - World scale Y
|
2025-12-05 23:03:31 +08:00
|
|
|
|
*/
|
2025-12-07 01:00:35 +08:00
|
|
|
|
burst(
|
|
|
|
|
|
pool: ParticlePool,
|
|
|
|
|
|
count: number,
|
|
|
|
|
|
worldX: number,
|
|
|
|
|
|
worldY: number,
|
|
|
|
|
|
worldRotation: number = 0,
|
|
|
|
|
|
worldScaleX: number = 1,
|
|
|
|
|
|
worldScaleY: number = 1
|
|
|
|
|
|
): number {
|
2025-12-05 23:03:31 +08:00
|
|
|
|
let emitted = 0;
|
|
|
|
|
|
for (let i = 0; i < count; i++) {
|
|
|
|
|
|
const p = pool.spawn();
|
|
|
|
|
|
if (p) {
|
2025-12-07 01:00:35 +08:00
|
|
|
|
this._initParticle(p, worldX, worldY, worldRotation, worldScaleX, worldScaleY);
|
2025-12-05 23:03:31 +08:00
|
|
|
|
emitted++;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return emitted;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 重置发射器
|
|
|
|
|
|
* Reset emitter
|
|
|
|
|
|
*/
|
|
|
|
|
|
reset(): void {
|
|
|
|
|
|
this._emissionAccumulator = 0;
|
|
|
|
|
|
this._isEmitting = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-07 01:00:35 +08:00
|
|
|
|
private _initParticle(
|
|
|
|
|
|
p: Particle,
|
|
|
|
|
|
worldX: number,
|
|
|
|
|
|
worldY: number,
|
|
|
|
|
|
worldRotation: number = 0,
|
|
|
|
|
|
worldScaleX: number = 1,
|
|
|
|
|
|
worldScaleY: number = 1
|
|
|
|
|
|
): void {
|
2025-12-05 23:03:31 +08:00
|
|
|
|
const config = this.config;
|
|
|
|
|
|
|
2025-12-07 01:00:35 +08:00
|
|
|
|
// 获取形状偏移 | Get shape offset
|
2025-12-05 23:03:31 +08:00
|
|
|
|
const [ox, oy] = this._getShapeOffset();
|
2025-12-07 01:00:35 +08:00
|
|
|
|
|
|
|
|
|
|
// 应用旋转和缩放到发射偏移 | Apply rotation and scale to emission offset
|
|
|
|
|
|
// 先缩放,再旋转 | Scale first, then rotate
|
|
|
|
|
|
const scaledOx = ox * worldScaleX;
|
|
|
|
|
|
const scaledOy = oy * worldScaleY;
|
|
|
|
|
|
const cos = Math.cos(worldRotation);
|
|
|
|
|
|
const sin = Math.sin(worldRotation);
|
|
|
|
|
|
const rotatedOx = scaledOx * cos - scaledOy * sin;
|
|
|
|
|
|
const rotatedOy = scaledOx * sin + scaledOy * cos;
|
|
|
|
|
|
|
|
|
|
|
|
// 位置 | Position
|
|
|
|
|
|
p.x = worldX + rotatedOx;
|
|
|
|
|
|
p.y = worldY + rotatedOy;
|
2025-12-05 23:03:31 +08:00
|
|
|
|
|
|
|
|
|
|
// 生命时间 | Lifetime
|
|
|
|
|
|
p.lifetime = randomRange(config.lifetime.min, config.lifetime.max);
|
|
|
|
|
|
p.age = 0;
|
|
|
|
|
|
|
2025-12-07 01:00:35 +08:00
|
|
|
|
// 速度方向(应用世界旋转)| Velocity direction (apply world rotation)
|
|
|
|
|
|
const baseDir = config.direction + randomRange(-config.directionSpread / 2, config.directionSpread / 2);
|
|
|
|
|
|
const dir = baseDir + worldRotation;
|
2025-12-05 23:03:31 +08:00
|
|
|
|
const speed = randomRange(config.speed.min, config.speed.max);
|
2025-12-07 01:00:35 +08:00
|
|
|
|
|
|
|
|
|
|
// 速度也应用缩放(使用平均缩放)| Speed also applies scale (use average scale)
|
|
|
|
|
|
const avgScale = (worldScaleX + worldScaleY) / 2;
|
|
|
|
|
|
p.vx = Math.cos(dir) * speed * avgScale;
|
|
|
|
|
|
p.vy = Math.sin(dir) * speed * avgScale;
|
2025-12-05 23:03:31 +08:00
|
|
|
|
|
|
|
|
|
|
// 加速度(重力)| 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);
|
|
|
|
|
|
|
2025-12-07 01:00:35 +08:00
|
|
|
|
// 缩放(应用世界缩放)| Scale (apply world scale)
|
|
|
|
|
|
const baseScale = randomRange(config.startScale.min, config.startScale.max);
|
|
|
|
|
|
p.scaleX = baseScale * worldScaleX;
|
|
|
|
|
|
p.scaleY = baseScale * worldScaleY;
|
|
|
|
|
|
p.startScaleX = p.scaleX;
|
|
|
|
|
|
p.startScaleY = p.scaleY;
|
2025-12-05 23:03:31 +08:00
|
|
|
|
|
|
|
|
|
|
// 颜色 | 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));
|
|
|
|
|
|
}
|