子发射器

This commit is contained in:
tianmo committed 2026-10-22 16:49:53 +08:00
1 parent d4ebb18777
commit d224939efd
33 files changed
+5690 -1355

No files matched your search

+189 -14
View File
@@ -3,10 +3,10 @@ import { pathDistributionProgress, sampleEmitterShape, sampleEmissionVelocityAng
import { TrailController, createTrailState, resetTrailState, type ParticleTrailState } from './particleTrail'
import { pickImageResource, pickTrailResource, imageFrameState, imageFrameTexture } from './particleResources'
import type { SceneCollider, EmitterConfig, ParticleState } from './particleTypes'
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 } from './particleMath'
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'
@@ -40,8 +40,54 @@ interface Particle {
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 {
@@ -55,8 +101,21 @@ export class ParticleEmitter extends Container {
/** 用于“统一时间”延迟:记录吸附从关闭切换为开启时的发射器时间。 */
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
@@ -118,8 +177,14 @@ export class ParticleEmitter extends Container {
trail: createTrailState(),
resourceId: 1,
colorSample: 0xffffff,
colorAdd: 0,
colorOverride: null,
currentScaleX: 1,
currentScaleY: 1,
imageFrameIndex: 0,
collisionPaused: false,
collisionCount: 0,
eventDepth: 0,
})
}
}
@@ -129,6 +194,7 @@ export class ParticleEmitter extends Container {
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
@@ -147,6 +213,8 @@ export class ParticleEmitter extends Container {
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
@@ -203,7 +271,7 @@ export class ParticleEmitter extends Container {
// 发射时长只属于持续模式;爆发模式在延迟结束时执行一次,不显示也不消耗持续段。
const withinDuration = emissionControl === 'force' || (emissionControl === 'normal' && (cfg.mode === 'burst' || this._elapsed < cfg.delay + Math.max(1 / 30, cfg.duration)))
// 发射(仅在窗口内且未超时长)
if (inWindow && withinDuration) {
if (this.autoEmission && inWindow && withinDuration) {
if (cfg.mode === 'stream') {
this.accumulator += dt * cfg.rate
while (this.accumulator >= 1) { this.accumulator -= 1; this.spawnOne() }
@@ -219,6 +287,24 @@ export class ParticleEmitter extends Container {
}
}
// 处理子发射器的延迟发射队列;新粒子在本帧就会参与后续更新与录制。
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]
@@ -385,6 +471,8 @@ export class ParticleEmitter extends Container {
: 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
@@ -395,11 +483,13 @@ export class ParticleEmitter extends Container {
? curveAt(resource.alphaCurve, t, this.resolveCurveMode(resource.alphaCurveMode))
: baseAlpha, 1)
const colorStart = resource?.colorStart || '#ffffff'
const rgb = resource?.colorMode === 'lifetime'
? gradientColorAt(resource.colorGradient, t)
: resource?.colorMode === 'random'
? p.colorSample
: hexToNumber(colorStart)
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)
@@ -467,15 +557,70 @@ export class ParticleEmitter extends Container {
}
}
private spawnOne() {
/** 由跨系统事件触发:在给定的数学世界坐标处生成一颗目标系统的粒子。 */
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 // 池满
const origin = this.spawnOrigin()
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 dirAng = sampleEmissionVelocityAngle(cfg, origin, this.followSpawnAngle, 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++
@@ -494,6 +639,8 @@ export class ParticleEmitter extends Container {
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
@@ -531,7 +678,7 @@ export class ParticleEmitter extends Container {
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())
: 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
@@ -549,23 +696,49 @@ export class ParticleEmitter extends Container {
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.colorSample
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
@@ -657,6 +830,7 @@ export class ParticleEmitter extends Container {
/** 清空所有粒子并重置发射计时 */
reset() {
this.pendingEventSpawns.length = 0
for (const p of this.pool) {
p.active = false
p.sprite.visible = false
@@ -678,6 +852,7 @@ export class ParticleEmitter extends Container {
* 发射计时与随机序列回到周期起点,使每一周期产生完全相同的发射事件。
*/
restartEmissionCycle() {
this.pendingEventSpawns.length = 0
this.accumulator = 0
this.burstTimer = 0
this._spawnSerial = 0