import { Container, Sprite, Graphics } from 'pixi.js' import { pathDistributionProgress, sampleEmitterShape, sampleEmissionVelocityAngle, type EmitterShapeSample } from './emitterShape' import { TrailController, createTrailState, resetTrailState, type ParticleTrailState } from './particleTrail' import { pickImageResource, pickTrailResource, imageFrameState, imageFrameTexture } from './particleResources' import type { SceneCollider, EmitterConfig, ParticleState, ParticleInheritAttribute } from './particleTypes' import { defaultConfig, ensureEmitterConfig } from './particleConfig' import { BLEND_MAP, MAX_PARTICLES } from './particleConstants' import { lerp, finite, localToWorldMath, worldMathToLocal, localVelocityToWorldMath, worldVelocityToLocalMath, resolveColliderPoint, tFromLife, seededUnit, initialValue, overLifeValue, curveAt, mulberry32, hexToNumber, gradientColorAt, fieldFalloff, vortexAcceleration, addRgbColors } from './particleMath' // 保留原有入口,现有调用方无需随内部拆分修改。 export type * from './particleTypes' export { defaultConfig, ensureEmitterConfig } from './particleConfig' interface Particle { sprite: Sprite active: boolean boneName: string spawnId: number life: number maxLife: number x: number; y: number vx: number; vy: number scaleStartX: number; scaleStartY: number alphaStart: number; alphaEnd: number rotation: number; rotationSpeed: number speedScale: number speedScaleTarget: number scaleOverLifeX: number; scaleOverLifeY: number noisePhaseX: number; noisePhaseY: number gravityStrength: number windStrength: number attractionActive: boolean attractionElapsed: number attractionStartWorldX: number attractionStartWorldY: number attractionCurveSide: number /** 拖尾运行状态(实现见 particleTrail.ts) */ trail: ParticleTrailState resourceId: number /** 随机颜色模式下,粒子出生时从卡片色带采样并在生命周期内保持的颜色。 */ colorSample: number /** 父粒子颜色,逐通道相加到粒子自身颜色。 */ colorAdd: number /** 父粒子颜色覆盖;null 表示不覆盖。 */ colorOverride: number | null /** 当前渲染缩放,用于事件继承。 */ currentScaleX: number currentScaleY: number imageFrameIndex: number collisionPaused: boolean /** 当前粒子累计发生的碰撞次数。 */ collisionCount: number /** 由事件链生成的层级;0 = 正常发射。 */ eventDepth: number } export type ParticleEventKind = 'birth' | 'death' | 'collision' export interface ParticleEventInfo { kind: ParticleEventKind /** 事件位置的数学世界坐标。 */ worldX: number worldY: number rotation: number spawnId: number /** 0 = 正常发射;数值越大表示事件链越深。 */ depth: number colorHex: number scaleX: number scaleY: number maxLife: number collisionCount: number normalX?: number normalY?: number } /** 子发射器从父粒子继承的数据。 */ export interface ParticleInheritData { colorHex: number scaleX: number scaleY: number rotation: number life: number } export interface ParticleInheritOptions { attributes: ParticleInheritAttribute[] colorMode: 'add' | 'replace' data: ParticleInheritData } export class ParticleEmitter extends Container { private pool: Particle[] = [] private cfg: EmitterConfig private accumulator = 0 private burstTimer = 0 private _spawnSerial = 0 /** 发射器累计运行时间(秒) — 非响应式,避免污染 config */ private _elapsed = 0 /** 用于“统一时间”延迟:记录吸附从关闭切换为开启时的发射器时间。 */ private _attractionEnabledAt = 0 private _attractionWasEnabled = false /** 子发射器关闭自动发射,只接收父粒子事件生成的粒子。 */ private autoEmission = true /** 事件粒子延迟队列:子发射器 delay 表示在父事件之后再延迟多久发射。 */ private pendingEventSpawns: Array<{ worldX: number worldY: number depth: number inherit?: ParticleInheritOptions count: number remaining: number }> = [] /** 活动粒子状态快照(供骨架 / 导出) */ states: ParticleState[] = [] /** 粒子出生/死亡/碰撞事件回调(由 EmitterSync 接入跨系统事件)。 */ onParticleEvent: ((event: ParticleEventInfo) => void) | null = null /** 确定性随机(种子>0 时可复现) */ private rng: () => number = Math.random private _lastSeed = -1 private trailDebugGfx = new Graphics() private trail: TrailController private colliders: SceneCollider[] = [] private followSpawnX = 0 private followSpawnY = 0 private followSpawnAngle = 0 private spawnPathSampler: ((progress: number) => { x: number; y: number; angle: number } | null) | null = null private spawnPathClosed = false private followExportCurveMode: '线性' | '贝塞尔' = '线性' private simulationTransform: { centerX: number; centerY: number; rotation: number; scaleX?: number; scaleY?: number } | null = null constructor(config: EmitterConfig, maxParticles = MAX_PARTICLES) { super() this.cfg = ensureEmitterConfig(config) this.trail = new TrailController(this) this._ensurePool(maxParticles) this.addChild(this.trailDebugGfx) } setFollowExportCurveMode(mode: '线性' | '贝塞尔') { this.followExportCurveMode = mode } private resolveCurveMode(mode: '跟随导出' | '线性' | '贝塞尔') { return mode === '跟随导出' ? this.followExportCurveMode : mode } /** 仅在种子变化时重建随机源;种子不变时让序列持续推进,保证"确定性随机序列"语义 */ private reseed() { const s = this.cfg.seed if (s === this._lastSeed) return this._lastSeed = s this.rng = mulberry32(s >>> 0) } private _ensurePool(n: number) { while (this.pool.length < n) { const poolIndex = this.pool.length const sprite = new Sprite(this.cfg.texture) sprite.blendMode = BLEND_MAP[this.cfg.blend] as any sprite.anchor.set(0.5) sprite.visible = false this.addChild(sprite) this.pool.push({ sprite, active: false, boneName: 'p_' + poolIndex, spawnId: -1, life: 0, maxLife: 1, x: 0, y: 0, vx: 0, vy: 0, scaleStartX: 1, scaleStartY: 1, alphaStart: 1, alphaEnd: 1, rotation: 0, rotationSpeed: 0, speedScale: 1, speedScaleTarget: 1, scaleOverLifeX: 1, scaleOverLifeY: 1, noisePhaseX: 0, noisePhaseY: 0, gravityStrength: 980, windStrength: 100, attractionActive: false, attractionElapsed: 0, attractionStartWorldX: 0, attractionStartWorldY: 0, attractionCurveSide: 1, trail: createTrailState(), resourceId: 1, colorSample: 0xffffff, colorAdd: 0, colorOverride: null, currentScaleX: 1, currentScaleY: 1, imageFrameIndex: 0, collisionPaused: false, collisionCount: 0, eventDepth: 0, }) } } setSpawnPathSampler(sampler: ((progress: number) => { x: number; y: number; angle: number } | null) | null, closed = false) { this.spawnPathSampler = sampler this.spawnPathClosed = closed } setColliders(colliders: SceneCollider[]) { this.colliders = colliders } setAutoEmission(enabled: boolean) { this.autoEmission = enabled } setEmitterFollowSpawn(x: number, y: number, angleDegrees: number) { this.followSpawnX = x this.followSpawnY = y this.followSpawnAngle = angleDegrees } setSimulationTransform(centerX: number, centerY: number, rotation: number, scaleX?: number, scaleY?: number) { this.simulationTransform = { centerX, centerY, rotation, scaleX, scaleY } } private applyColliders(p: Particle, particleIndex: number) { if (!this.cfg.collision || !this.colliders.length) return false const cfg = this.cfg let world = localToWorldMath(cfg, p.x, p.y, this.simulationTransform) let velocity = localVelocityToWorldMath(cfg, p.vx, p.vy, this.simulationTransform) for (const collider of this.colliders) { if (!collider.enabled) continue const hit = resolveColliderPoint(collider, world.x, world.y) if (!hit) continue p.collisionCount++ this.emitWorldEvent('collision', p, hit.x, hit.y, { normalX: hit.nx, normalY: hit.ny }) if (collider.response === 'destroy') { this.kill(particleIndex) return true } world = { x: hit.x, y: hit.y } if (collider.response === 'pause') { p.collisionPaused = true velocity = { x: 0, y: 0 } } else { const normalSpeed = velocity.x * hit.nx + velocity.y * hit.ny const tangentX = velocity.x - normalSpeed * hit.nx const tangentY = velocity.y - normalSpeed * hit.ny const elasticity = Math.min(1, Math.max(0, collider.elasticity)) const tangentScale = 1 - Math.min(1, Math.max(0, collider.friction)) const reflectedNormal = normalSpeed < 0 ? -normalSpeed * elasticity : normalSpeed velocity.x = tangentX * tangentScale + reflectedNormal * hit.nx velocity.y = tangentY * tangentScale + reflectedNormal * hit.ny } const local = worldMathToLocal(cfg, world.x, world.y, this.simulationTransform) const localVelocity = worldVelocityToLocalMath(cfg, velocity.x, velocity.y, this.simulationTransform) p.x = local.x p.y = local.y p.vx = localVelocity.x p.vy = localVelocity.y } return false } /** 每帧更新;循环段录制时可强制发射或停止发射,以生成稳定衔接帧。 */ update(dt: number, emissionControl: 'normal' | 'force' | 'off' = 'normal'): ParticleState[] { // HMR 会保留已创建的系统对象;旧对象缺少新增字段时在这里即时补齐。 const cfg = this.cfg if ( cfg.lifeMode == null || cfg.speedMode == null || cfg.scaleMode == null || cfg.shapeRangeVersion !== 1 || cfg.initialRotationMode == null || cfg.speedOverLifeEnabled == null || cfg.scaleOverLifeEnabled == null || cfg.rotationOverLifeEnabled == null || !Array.isArray(cfg.scaleOverLifeCurve) || cfg.attraction == null || cfg.trailBoneCount == null ) ensureEmitterConfig(cfg) this.reseed() // 种子变化即重建随机源 // 累计发射时间 this._elapsed += dt if (cfg.attraction && !this._attractionWasEnabled) { this._attractionEnabledAt = this._elapsed for (const particle of this.pool) { particle.attractionActive = false particle.attractionElapsed = 0 } } else if (!cfg.attraction && this._attractionWasEnabled) { for (const particle of this.pool) particle.attractionActive = false } this._attractionWasEnabled = cfg.attraction const inWindow = emissionControl === 'force' || (emissionControl === 'normal' && (cfg.delay <= 0 || this._elapsed >= cfg.delay)) // 发射时长只属于持续模式;爆发模式在延迟结束时执行一次,不显示也不消耗持续段。 const withinDuration = emissionControl === 'force' || (emissionControl === 'normal' && (cfg.mode === 'burst' || this._elapsed < cfg.delay + Math.max(1 / 30, cfg.duration))) // 发射(仅在窗口内且未超时长) if (this.autoEmission && inWindow && withinDuration) { if (cfg.mode === 'stream') { this.accumulator += dt * cfg.rate while (this.accumulator >= 1) { this.accumulator -= 1; this.spawnOne() } } else { this.burstTimer += dt if (cfg.burstLoop) { if (this.burstTimer >= 1) { this.burstTimer = 0; this.emitBurst() } // update(0) 只用于两个固定帧之间刷新画面,不能被识别为首次爆发; // 否则 0 <= 0 会在第 1 帧前额外生成一批粒子。 } else if (dt > 0 && this.burstTimer <= dt) { this.emitBurst() } } } // 处理子发射器的延迟发射队列;新粒子在本帧就会参与后续更新与录制。 if (this.pendingEventSpawns.length && dt > 0) { const ready: typeof this.pendingEventSpawns = [] const waiting: typeof this.pendingEventSpawns = [] for (const pending of this.pendingEventSpawns) { pending.remaining -= dt if (pending.remaining <= 0) ready.push(pending) else waiting.push(pending) } this.pendingEventSpawns = waiting for (const pending of ready) { const local = worldMathToLocal(this.cfg, pending.worldX, pending.worldY, this.simulationTransform) for (let index = 0; index < pending.count; index++) { if (!this.spawnOne({ x: local.x, y: local.y }, pending.depth, pending.inherit)) break } } } const states: ParticleState[] = [] for (let i = 0; i < this.pool.length; i++) { const p = this.pool[i] if (!p.active) continue p.life -= dt // 粒子自身生命周期优先:即使正在吸附途中,生命耗尽也立即死亡,不因吸附时长延寿。 if (p.life <= 0) { this.kill(i); continue } const lifeT = tFromLife(p) if (!p.collisionPaused && cfg.useGravity) { const gravityMultiplier = Math.min(1, Math.max(0, curveAt(cfg.gravityCurve, lifeT))) const gravityForce = (cfg.gravityMode === 'random' ? p.gravityStrength : cfg.gravityMin) * gravityMultiplier const gravityAngle = cfg.gravityDirection * Math.PI / 180 p.vx += Math.cos(gravityAngle) * gravityForce * dt p.vy += -Math.sin(gravityAngle) * gravityForce * dt } // 风力 if (!p.collisionPaused && cfg.wind) { const windMultiplier = Math.min(1, Math.max(0, curveAt(cfg.windCurve, lifeT))) const windForce = (cfg.windMode === 'random' ? p.windStrength : cfg.windMin) * windMultiplier const windAngle = cfg.windDirection * Math.PI / 180 p.vx += Math.cos(windAngle) * windForce * dt p.vy += -Math.sin(windAngle) * windForce * dt } // 全局力场:先把粒子局部坐标转换到场景数学坐标,计算力后再转回发射器局部坐标。 if (!p.collisionPaused && cfg.forceField && cfg.forceStrength !== 0 && cfg.forceRadius > 0) { const rootScaleX = Math.max(0.0001, Math.abs(cfg.rootScaleX)) const rootScaleY = Math.max(0.0001, Math.abs(cfg.rootScaleY)) const rootAngle = -(this.simulationTransform?.rotation ?? cfg.rootRotation) * Math.PI / 180 const cosRoot = Math.cos(rootAngle), sinRoot = Math.sin(rootAngle) const centerX = this.simulationTransform?.centerX ?? cfg.centerX const centerY = this.simulationTransform?.centerY ?? cfg.centerY const particleScreenX = centerX + cosRoot * rootScaleX * p.x - sinRoot * rootScaleY * p.y const particleScreenY = -centerY + sinRoot * rootScaleX * p.x + cosRoot * rootScaleY * p.y const particleWorldX = particleScreenX const particleWorldY = -particleScreenY const dx = cfg.forceCenterX - particleWorldX const dy = cfg.forceCenterY - particleWorldY const distance = Math.hypot(dx, dy) if (distance > 0.0001 && distance <= cfg.forceRadius) { const falloff = fieldFalloff(cfg.forceFalloff, distance, cfg.forceRadius) const force = cfg.forceStrength * falloff * 10 const accelWorldX = dx / distance * force const accelWorldY = dy / distance * force const accelScreenX = accelWorldX const accelScreenY = -accelWorldY p.vx += (cosRoot * accelScreenX + sinRoot * accelScreenY) / rootScaleX * dt p.vy += (-sinRoot * accelScreenX + cosRoot * accelScreenY) / rootScaleY * dt } } // 漩涡力在全局数学坐标中沿半径切线方向作用。 if (!p.collisionPaused && cfg.vortex && cfg.vortexStrength !== 0 && cfg.vortexRadius > 0) { const world = localToWorldMath(cfg, p.x, p.y, this.simulationTransform) const acceleration = vortexAcceleration( world.x, world.y, cfg.vortexCenterX, cfg.vortexCenterY, cfg.vortexStrength, cfg.vortexRadius, cfg.vortexFalloff, ) const localAcceleration = worldVelocityToLocalMath(cfg, acceleration.x, acceleration.y, this.simulationTransform) p.vx += localAcceleration.x * dt p.vy += localAcceleration.y * dt } // 阻尼:速度按衰减系数逐渐减小 if (!p.collisionPaused && cfg.damping > 0) { const f = Math.max(0, 1 - cfg.damping * dt) p.vx *= f; p.vy *= f } if (!p.collisionPaused) { p.x += p.vx * dt p.y += p.vy * dt if (this.applyColliders(p, i)) continue } else { p.vx = 0 p.vy = 0 } // 吸附使用场景全局数学坐标。延迟结束时锁定当前位置,随后沿直线或确定性弧线到达目标。 if (cfg.attraction && !p.collisionPaused) { const age = p.maxLife - p.life const delayReached = cfg.attractionDelayMode === 'particle' ? age >= Math.max(0, cfg.attractionDelay) : this._elapsed - this._attractionEnabledAt >= Math.max(0, cfg.attractionDelay) if (delayReached) { const currentWorld = localToWorldMath(cfg, p.x, p.y, this.simulationTransform) const targetX = cfg.attractionCenterX const targetY = cfg.attractionCenterY const radius = Math.max(0, cfg.attractionRadius) if (!p.attractionActive) { if (Math.hypot(targetX - currentWorld.x, targetY - currentWorld.y) <= radius) { this.kill(i) continue } p.attractionActive = true p.attractionElapsed = 0 p.attractionStartWorldX = currentWorld.x p.attractionStartWorldY = currentWorld.y } p.attractionElapsed += dt const duration = Math.max(0.0001, cfg.attractionDuration) const progress = Math.min(1, p.attractionElapsed / duration) let worldX = lerp(p.attractionStartWorldX, targetX, progress) let worldY = lerp(p.attractionStartWorldY, targetY, progress) if (cfg.attractionPath === 'curve') { const dx = targetX - p.attractionStartWorldX const dy = targetY - p.attractionStartWorldY const distance = Math.hypot(dx, dy) if (distance > 0.0001) { const curveOffset = distance * 0.25 * p.attractionCurveSide const controlX = (p.attractionStartWorldX + targetX) * 0.5 - dy / distance * curveOffset const controlY = (p.attractionStartWorldY + targetY) * 0.5 + dx / distance * curveOffset const oneMinusT = 1 - progress worldX = oneMinusT * oneMinusT * p.attractionStartWorldX + 2 * oneMinusT * progress * controlX + progress * progress * targetX worldY = oneMinusT * oneMinusT * p.attractionStartWorldY + 2 * oneMinusT * progress * controlY + progress * progress * targetY } } const remainingDistance = Math.hypot(targetX - worldX, targetY - worldY) if (progress >= 1 || remainingDistance <= radius) { this.kill(i) continue } const local = worldMathToLocal(cfg, worldX, worldY, this.simulationTransform) p.x = local.x p.y = local.y // 吸附期间位置由路径控制,清除速度以免抵达后发生额外积分偏移。 p.vx = 0 p.vy = 0 } } const t = lifeT const s = p.sprite const resource = cfg.imageResources.find((item) => item.id === p.resourceId) if (resource) { const frameState = imageFrameState(resource, p.maxLife - p.life) p.imageFrameIndex = frameState.index s.texture = imageFrameTexture(resource, cfg.texture, p.imageFrameIndex) s.visible = frameState.visible } if (cfg.speedOverLifeEnabled) { const target = finite(overLifeValue(cfg.speedOverLifeMode, cfg.speedOverLifeMin, cfg.speedOverLifeMax, cfg.speedOverLifeCurve, t, p.speedScaleTarget), 1) const ratio = p.speedScale === 0 ? target : target / p.speedScale p.vx *= ratio; p.vy *= ratio; p.speedScale = target } let rotationSpeed = p.rotationSpeed if (cfg.rotationOverLifeEnabled && cfg.rotationOverLifeMode === 'curve') { rotationSpeed = lerp(cfg.rotationSpeedMin, cfg.rotationSpeedMax, curveAt(cfg.rotationOverLifeCurve, t)) } p.rotation += rotationSpeed * dt let renderX = p.x, renderY = p.y if (cfg.noise && cfg.noiseAmt > 0 && cfg.noiseRange > 0 && cfg.noiseSpeed > 0) { const age = p.maxLife - p.life const amplitude = Math.max(0, cfg.noiseRange) * Math.min(1, Math.max(0, cfg.noiseAmt) / 100) * 0.5 const phase = age * Math.max(0, cfg.noiseSpeed) * Math.PI * 2 // 减去出生时的初始采样,确保粒子仍从发射点开始;差值最大不超过设定波动范围。 renderX += (Math.sin(p.noisePhaseX + phase) - Math.sin(p.noisePhaseX)) * amplitude renderY += (Math.sin(p.noisePhaseY + phase * 0.83) - Math.sin(p.noisePhaseY)) * amplitude } s.x = renderX; s.y = renderY; s.rotation = -p.rotation * Math.PI / 180 let scaleMulX = 1, scaleMulY = 1 if (cfg.scaleOverLifeEnabled) { scaleMulX = overLifeValue(cfg.scaleOverLifeMode, cfg.scaleOverLifeMin, cfg.scaleOverLifeMax, cfg.scaleOverLifeCurve, t, p.scaleOverLifeX) scaleMulY = cfg.scaleOverLifeAxisMode === 'separate' ? overLifeValue(cfg.scaleOverLifeMode, cfg.scaleYOverLifeMin, cfg.scaleYOverLifeMax, cfg.scaleYOverLifeCurve, t, p.scaleOverLifeY) : scaleMulX } s.scale.set(finite(p.scaleStartX * scaleMulX, 1), finite(p.scaleStartY * scaleMulY, 1)) p.currentScaleX = s.scale.x p.currentScaleY = s.scale.y const baseAlpha = cfg.alphaMode === 'curve' ? curveAt(cfg.alphaCurve, t, this.resolveCurveMode(cfg.curveMode)) * cfg.alphaScale : cfg.alpha * cfg.alphaScale // 开启卡片独立透明度后,覆盖系统透明度;固定值不会再受系统曲线影响。 s.alpha = finite(resource?.independentAlpha === 'fixed' ? resource.alpha : resource?.independentAlpha === 'curve' ? curveAt(resource.alphaCurve, t, this.resolveCurveMode(resource.alphaCurveMode)) : baseAlpha, 1) const colorStart = resource?.colorStart || '#ffffff' const baseRgb = resource?.colorMode === 'lifetime' ? gradientColorAt(resource.colorGradient, t) : resource?.colorMode === 'random' ? p.colorSample : hexToNumber(colorStart) // 继承颜色:按配置逐通道相加,或直接覆盖目标系统颜色。 const rgb = p.colorOverride ?? addRgbColors(baseRgb, p.colorAdd) s.tint = rgb s.blendMode = BLEND_MAP[resource?.blend || 'normal'] as any const trailState = this.trail.update(p.trail, cfg, p.boneName, renderX, renderY, t, s) const world = localToWorldMath(cfg, renderX, renderY, this.simulationTransform) const rootRotation = this.simulationTransform?.rotation ?? cfg.rootRotation const rootScaleX = Math.abs(this.simulationTransform?.scaleX ?? cfg.rootScaleX) const rootScaleY = Math.abs(this.simulationTransform?.scaleY ?? cfg.rootScaleY) const worldTrail = trailState ? { ...trailState, bones: trailState.bones.map((bone) => { const point = localToWorldMath(cfg, bone.x, bone.y, this.simulationTransform) return { ...bone, worldX: point.x, worldY: point.y, worldRotation: rootRotation - bone.rotation } }), } : undefined states.push({ boneName: p.boneName, spawnId: p.spawnId, x: renderX, y: renderY, rotation: p.rotation, scaleX: s.scale.x, scaleY: s.scale.y, worldX: world.x, worldY: world.y, worldRotation: rootRotation + p.rotation, worldScaleX: s.scale.x * rootScaleX, worldScaleY: s.scale.y * rootScaleY, alpha: s.alpha, colorHex: rgb, resourceId: p.resourceId, imageFrameIndex: p.imageFrameIndex, imageVisible: s.visible, trailState: worldTrail, active: true }) } this.states = states return states } /** 在画布上绘制实际蒙皮网格;仅用于编辑器调试显示,不参与录制或导出。 */ drawTrailMeshDebug(visible: boolean) { this.trail.drawDebug(this.trailDebugGfx, visible, this.cfg, this.pool.map((p) => p.trail)) } private emitBurst() { for (let i = 0; i < this.cfg.burstCount; i++) this.spawnOne() } private spawnOrigin(): EmitterShapeSample { const cfg = this.cfg // 局部原点 = 0(root 中心,centerX 由发射器容器 position 补回);粒子相对 root 中心 = 偏移X/Y let ox = cfg.offsetX, oy = cfg.offsetY // 采用数学坐标系(Y 向上为正):world 为屏幕坐标(向下为正),故 y 取负 oy = -oy if (cfg.shape === 'path' && cfg.spawnPathId > 0 && this.spawnPathSampler) { const count = this.estimatedEmissionCount() const progress = pathDistributionProgress(cfg.spawnPathDistribution, this._spawnSerial, count, this.spawnPathClosed, this.rng) const pathSample = this.spawnPathSampler(progress) if (pathSample) { const origin = this.applyFollowToOrigin(pathSample.x + ox, pathSample.y + oy) return { ...origin, radialAngle: 0, pathAngle: pathSample.angle } } } const sample = sampleEmitterShape(cfg, this.rng) const origin = this.applyFollowToOrigin(ox + sample.x, oy + sample.y) return { ...origin, radialAngle: sample.radialAngle } } private estimatedEmissionCount() { const cfg = this.cfg return cfg.mode === 'burst' ? Math.max(1, Math.round(cfg.burstCount)) : Math.max(1, Math.floor(cfg.rate * (cfg.streamBehavior === 'loop' ? Math.max(1, cfg.loopDurationFrames) / 30 : Math.max(0, cfg.duration)))) } private applyFollowToOrigin(x: number, y: number) { const angle = this.followSpawnAngle * Math.PI / 180 const cos = Math.cos(angle), sin = Math.sin(angle) return { x: this.followSpawnX + cos * x - sin * y, y: this.followSpawnY + sin * x + cos * y, } } /** 由跨系统事件触发:在给定的数学世界坐标处生成一颗目标系统的粒子。 */ spawnEventParticle(worldX: number, worldY: number, depth = 1, inherit?: ParticleInheritOptions, count?: number): boolean { if (depth > 2) return false // 子发射器按自己的发射模式决定每次事件生成多少颗:爆发=单次数量,持续=1。 const requested = count ?? (this.cfg.mode === 'burst' ? this.cfg.burstCount : 1) const total = Math.max(1, Math.min(MAX_PARTICLES, Math.round(Number(requested) || 1))) // 子发射器 delay 表示在父事件之后再延迟多久发射。 const delay = Math.max(0, Number(this.cfg.delay) || 0) if (delay > 0) { this.pendingEventSpawns.push({ worldX, worldY, depth, inherit, count: total, remaining: delay }) return true } const local = worldMathToLocal(this.cfg, worldX, worldY, this.simulationTransform) let spawned = false for (let index = 0; index < total; index++) { if (!this.spawnOne({ x: local.x, y: local.y }, depth, inherit)) break spawned = true } return spawned } private emitWorldEvent( kind: ParticleEventKind, particle: Particle, worldX: number, worldY: number, extra?: { normalX?: number; normalY?: number }, ) { if (!this.onParticleEvent) return this.onParticleEvent({ kind, worldX, worldY, rotation: particle.rotation, spawnId: particle.spawnId, depth: particle.eventDepth, colorHex: particle.sprite.tint as number, scaleX: particle.currentScaleX, scaleY: particle.currentScaleY, maxLife: particle.maxLife, collisionCount: particle.collisionCount, ...extra, }) } private emitLocalEvent(kind: ParticleEventKind, particle: Particle, x: number, y: number) { if (!this.onParticleEvent) return const world = localToWorldMath(this.cfg, x, y, this.simulationTransform) this.emitWorldEvent(kind, particle, world.x, world.y) } private spawnOne(originOverride?: { x: number; y: number }, eventDepth = 0, inherit?: ParticleInheritOptions): boolean { const cfg = this.cfg let idx = this.pool.findIndex((p) => !p.active) if (idx === -1) return false // 池满 const origin = originOverride ? { x: originOverride.x, y: originOverride.y, radialAngle: 0 } : this.spawnOrigin() const { x: ox, y: oy } = origin const life = initialValue(cfg.lifeMode, cfg.lifeMin, cfg.lifeMax, cfg.lifeCurve, this.rng()) const speed = initialValue(cfg.speedMode, cfg.speedMin, cfg.speedMax, cfg.speedCurve, this.rng()) // 继承旋转作为整个子发射系统的方向偏移:旋转发射方向与粒子初始朝向。 const rotationOffset = inherit?.attributes.includes('rotation') ? inherit.data.rotation : 0 const dirAng = sampleEmissionVelocityAngle(cfg, origin, this.followSpawnAngle, this.rng) + rotationOffset * Math.PI / 180 const p = this.pool[idx] const resource = pickImageResource(cfg, this.rng) const spawnSerial = this._spawnSerial++ p.spawnId = spawnSerial // 拖尾资源使用独立的确定性采样,不改变粒子属性原有随机序列。 const trailResource = pickTrailResource(cfg, seededUnit(cfg.seed, spawnSerial, 0x5bd1e995)) // 周期内的发射序号决定噪声和吸附方向,不依赖复用到的粒子池槽位,确保相邻循环表现一致。 p.noisePhaseX = seededUnit(cfg.seed, spawnSerial, 0x68bc21eb) * Math.PI * 2 p.noisePhaseY = seededUnit(cfg.seed, spawnSerial, 0x02e5be93) * Math.PI * 2 p.gravityStrength = cfg.useGravity && cfg.gravityMode === 'random' ? lerp(cfg.gravityMin, cfg.gravityMax, this.rng()) : cfg.gravityMin p.windStrength = cfg.wind && cfg.windMode === 'random' ? lerp(cfg.windMin, cfg.windMax, this.rng()) : cfg.windMin p.attractionActive = false p.attractionElapsed = 0 p.collisionPaused = false p.collisionCount = 0 p.eventDepth = eventDepth p.attractionCurveSide = seededUnit(cfg.seed, spawnSerial, 0xa341316c) < 0.5 ? -1 : 1 p.trail.history.length = 0 p.trail.bones.length = 0 p.trail.currentWidth = 0 p.trail.currentAlpha = 0 p.trail.currentColor = 0xffffff p.trail.resourceId = trailResource.id const ratio = Math.min(100, Math.max(0, Number(trailResource.trailRatio) || 0)) const estimatedCount = cfg.mode === 'burst' ? Math.max(0, Math.round(cfg.burstCount)) : Math.max(0, Math.floor(cfg.rate * (cfg.streamBehavior === 'loop' ? Math.max(1, cfg.loopDurationFrames) / 30 : Math.max(0, cfg.duration)))) const forceSingleTrail = ratio > 0 && estimatedCount === 1 && spawnSerial === 0 const ratioSample = seededUnit(cfg.seed, spawnSerial, 0x27d4eb2d) * 100 const sampleRange = (mode: 'fixed' | 'random', fixed: number, min: number, max: number, salt: number) => { if (mode !== 'random') return Math.max(0, Number(fixed) || 0) const low = Math.max(0, Math.min(Number(min) || 0, Number(max) || 0)) const high = Math.max(0, Math.max(Number(min) || 0, Number(max) || 0)) return lerp(low, high, seededUnit(cfg.seed, spawnSerial, salt)) } p.trail.sampledLength = sampleRange(trailResource.lengthMode, trailResource.length, trailResource.lengthMin, trailResource.lengthMax, 0x165667b1) p.trail.sampledWidth = sampleRange(trailResource.widthSizeMode, trailResource.width, trailResource.widthMin, trailResource.widthMax, 0xd3a2646c) p.trail.enabled = cfg.trail && (forceSingleTrail || ratioSample < ratio) && p.trail.sampledLength > 0 && p.trail.sampledWidth > 0 if (p.trail.mesh) p.trail.mesh.visible = false p.active = true; p.life = life; p.maxLife = life p.x = ox; p.y = oy // 从出生点开始积累轨迹,使第一次更新时整组拖尾骨骼就能在首段轨迹上等距插值。 if (p.trail.enabled) p.trail.history.push({ x: ox, y: oy }) p.vx = Math.cos(dirAng) * speed p.vy = Math.sin(dirAng) * speed p.scaleStartX = initialValue(cfg.scaleMode, cfg.scaleMin, cfg.scaleMax, cfg.scaleCurve, this.rng()) * resource.scale p.scaleStartY = cfg.scaleAxisMode === 'separate' ? initialValue(cfg.scaleMode, cfg.scaleYMin, cfg.scaleYMax, cfg.scaleYCurve, this.rng()) * resource.scale : p.scaleStartX p.alphaStart = cfg.alpha; p.alphaEnd = cfg.alphaEnd p.rotation = cfg.initialRotationMode === 'direction' ? -Math.atan2(p.vy, p.vx) * 180 / Math.PI + cfg.initialRotationMin : initialValue(cfg.initialRotationMode, cfg.initialRotationMin, cfg.initialRotationMax, cfg.initialRotationCurve, this.rng()) + rotationOffset p.rotationSpeed = cfg.rotationOverLifeEnabled ? initialValue(cfg.rotationOverLifeMode, cfg.rotationSpeedMin, cfg.rotationSpeedMax, cfg.rotationOverLifeCurve, this.rng()) : 0 p.speedScale = 1 p.speedScaleTarget = cfg.speedOverLifeMode === 'random' ? lerp(cfg.speedOverLifeMin, cfg.speedOverLifeMax, this.rng()) : cfg.speedOverLifeMin p.scaleOverLifeX = cfg.scaleOverLifeMode === 'random' ? lerp(cfg.scaleOverLifeMin, cfg.scaleOverLifeMax, this.rng()) : cfg.scaleOverLifeMin p.scaleOverLifeY = cfg.scaleOverLifeMode === 'random' ? lerp(cfg.scaleYOverLifeMin, cfg.scaleYOverLifeMax, this.rng()) : cfg.scaleYOverLifeMin p.resourceId = resource.id p.colorSample = resource.colorMode === 'random' ? gradientColorAt(resource.colorGradient, seededUnit(cfg.seed, spawnSerial, 0x9e3779b9)) : hexToNumber(resource.colorStart) p.colorAdd = 0 p.colorOverride = null // 子发射器继承父粒子属性:颜色 / 大小 / 旋转 / 生命周期。 if (inherit) { const attributes = inherit.attributes if (attributes.includes('color')) { if (inherit.colorMode === 'replace') p.colorOverride = inherit.data.colorHex else p.colorAdd = inherit.data.colorHex } if (attributes.includes('scale')) { // 大小作为整个子发射系统的缩放:目标粒子自身缩放 × 父粒子当前缩放。 p.scaleStartX *= inherit.data.scaleX p.scaleStartY *= inherit.data.scaleY } // 旋转已在上面作为整体方向偏移应用,不再覆盖粒子自身旋转。 if (attributes.includes('life')) { p.life = Math.max(0.0001, inherit.data.life) p.maxLife = p.life } } p.currentScaleX = p.scaleStartX p.currentScaleY = p.scaleStartY const initialFrame = imageFrameState(resource, 0) p.imageFrameIndex = initialFrame.index p.sprite.texture = imageFrameTexture(resource, cfg.texture, initialFrame.index) p.sprite.anchor.set(resource.anchorX, resource.anchorY) p.sprite.blendMode = BLEND_MAP[resource.blend] as any p.sprite.tint = p.colorOverride ?? addRgbColors(p.colorSample, p.colorAdd) p.sprite.visible = true this.emitLocalEvent('birth', p, p.x, p.y) return true } private kill(i: number) { const p = this.pool[i] if (p.active) this.emitLocalEvent('death', p, p.x, p.y) p.active = false p.sprite.visible = false resetTrailState(p.trail) } clear() { this.pendingEventSpawns.length = 0 for (const p of this.pool) { p.active = false p.sprite.visible = false resetTrailState(p.trail) } } get activeCount() { let n = 0 for (const p of this.pool) if (p.active) n++ return n } /** 录制:返回当前活动粒子的深拷贝快照 */ capture(): ParticleState[] { const out: ParticleState[] = [] for (const p of this.pool) { if (!p.active) continue out.push({ // 拖尾记录的是最终渲染轨迹,因此粒子本体也必须记录 sprite 坐标(包含移动噪声等渲染偏移)。 boneName: p.boneName, spawnId: p.spawnId, x: p.sprite.x, y: p.sprite.y, rotation: p.rotation, scaleX: p.sprite.scale.x, scaleY: p.sprite.scale.y, ...this.captureWorldTransform(p), alpha: p.sprite.alpha, colorHex: p.sprite.tint as number, resourceId: p.resourceId, imageFrameIndex: p.imageFrameIndex, imageVisible: p.sprite.visible, active: true, trailState: this.cfg.trail && p.trail.bones.length ? { bones: p.trail.bones.map((bone) => { const point = localToWorldMath(this.cfg, bone.x, bone.y, this.simulationTransform) return { ...bone, worldX: point.x, worldY: point.y, worldRotation: (this.simulationTransform?.rotation ?? this.cfg.rootRotation) - bone.rotation } }), activeBoneCount: p.trail.bones.length, width: p.trail.currentWidth, alpha: p.trail.currentAlpha, colorHex: p.trail.currentColor, resourceId: p.trail.resourceId, } : undefined, }) } return out } private captureWorldTransform(p: Particle) { const point = localToWorldMath(this.cfg, p.sprite.x, p.sprite.y, this.simulationTransform) const rootRotation = this.simulationTransform?.rotation ?? this.cfg.rootRotation return { worldX: point.x, worldY: point.y, worldRotation: rootRotation + p.rotation, worldScaleX: p.sprite.scale.x * Math.abs(this.simulationTransform?.scaleX ?? this.cfg.rootScaleX), worldScaleY: p.sprite.scale.y * Math.abs(this.simulationTransform?.scaleY ?? this.cfg.rootScaleY), } } /** 回放:把快照还原到 sprite(非活动粒子隐藏) */ apply(states: ParticleState[]) { const byName = new Map() for (const s of states) byName.set(s.boneName, s) for (const p of this.pool) { const st = byName.get(p.boneName) if (!st) { p.active = false p.sprite.visible = false if (p.trail.mesh) p.trail.mesh.visible = false continue } p.active = st.active const s = p.sprite s.visible = st.active && st.imageVisible !== false s.x = st.x; s.y = st.y s.rotation = -st.rotation * Math.PI / 180 s.scale.set(st.scaleX, st.scaleY) s.alpha = st.alpha s.tint = st.colorHex if (st.resourceId != null) { const resource = this.cfg.imageResources.find((item) => item.id === st.resourceId) if (resource) { p.imageFrameIndex = st.imageFrameIndex ?? 0 s.texture = imageFrameTexture(resource, this.cfg.texture, p.imageFrameIndex) s.anchor.set(resource.anchorX, resource.anchorY) s.blendMode = BLEND_MAP[resource.blend] as any } } if (this.cfg.trail && st.trailState) { this.trail.applyState(p.trail, this.cfg, p.sprite, st.trailState) } else if (p.trail.mesh) p.trail.mesh.visible = false } } /** 清空所有粒子并重置发射计时 */ reset() { this.pendingEventSpawns.length = 0 for (const p of this.pool) { p.active = false p.sprite.visible = false resetTrailState(p.trail) } this.accumulator = 0 this.burstTimer = 0 this._spawnSerial = 0 this._elapsed = 0 this._attractionEnabledAt = 0 this._attractionWasEnabled = false // 从头重新模拟:任何整数种子(包括 0)都会回到同一随机序列。 this._lastSeed = this.cfg.seed this.rng = mulberry32(this.cfg.seed >>> 0) } /** * 开始下一个循环周期,但保留仍存活粒子的物理状态。 * 发射计时与随机序列回到周期起点,使每一周期产生完全相同的发射事件。 */ restartEmissionCycle() { this.pendingEventSpawns.length = 0 this.accumulator = 0 this.burstTimer = 0 this._spawnSerial = 0 this._elapsed = 0 this._attractionEnabledAt = 0 this._attractionWasEnabled = this.cfg.attraction this._lastSeed = this.cfg.seed this.rng = mulberry32(this.cfg.seed >>> 0) } /** 从当前重置状态向前模拟,用于时间轴第 0 帧的暖场。 */ warmup(frames: number, frameDuration: number) { const count = Math.max(0, Math.floor(Number(frames) || 0)) for (let frame = 0; frame < count; frame++) this.update(frameDuration) } }