子发射器
This commit is contained in:
1 parent
d4ebb18777
commit
d224939efd
33 files changed
+5690
-1355
No files matched your search
+189
-14
@@ -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
|
||||
|
||||
Reference in new issue
Block a user