Files
SpineParticlesWeb/src/core/particleEmitter.ts
T

872 lines
39 KiB
TypeScript

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<string, ParticleState>()
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)
}
}