代码拆分

This commit is contained in:
tianmo committed 2026-09-12 11:53:12 +08:00
1 parent 8bf16fe09f
commit ee72d09b23
20 files changed
+2760 -2450

No files matched your search

+45 -398
View File
@@ -1,10 +1,12 @@
import { Container, Sprite, Texture, IPointData, SimpleMesh, Graphics } from 'pixi.js'
import { pathDistributionProgress, sampleEmitterShape, sampleEmissionVelocityAngle, type EmissionAngleMode, type EmitterShapeSample } from './emitterShape'
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 { EmitMode, EmitShape, Blend, AttributeMode, RotationMode, ScaleAxisMode, CurvePoint, ColorGradientStop, ColliderShape, CollisionResponse, SceneCollider, ParticleImageResource, ParticleSequenceFrame, TrailImageResource, EmitterConfig, ParticleState, TrailBoneState, TrailState } from './particleTypes'
import type { SceneCollider, EmitterConfig, ParticleState } 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, cubicValue, curveAt, mulberry32, hexToNumber, blendRgb, gradientColorAt, hexToRgb, type SimulationTransform } from './particleMath'
import { lerp, finite, localToWorldMath, worldMathToLocal, localVelocityToWorldMath, worldVelocityToLocalMath, resolveColliderPoint, tFromLife, seededUnit, initialValue, overLifeValue, curveAt, mulberry32, hexToNumber, gradientColorAt } from './particleMath'
// 保留原有入口,现有调用方无需随内部拆分修改。
export type * from './particleTypes'
@@ -33,18 +35,8 @@ interface Particle {
attractionStartWorldX: number
attractionStartWorldY: number
attractionCurveSide: number
trailMesh: SimpleMesh | null
trailMeshSignature: string
trailHistory: { x: number; y: number }[]
trailBones: TrailBoneState[]
trailSkinWeights: { boneA: number; boneB: number; weight: number }[]
trailCurrentWidth: number
trailCurrentAlpha: number
trailCurrentColor: number
trailResourceId: number
trailEnabled: boolean
trailSampledLength: number
trailSampledWidth: number
/** 拖尾运行状态(实现见 particleTrail.ts) */
trail: ParticleTrailState
resourceId: number
/** 随机颜色模式下,粒子出生时从卡片色带采样并在生命周期内保持的颜色。 */
colorSample: number
@@ -52,7 +44,6 @@ interface Particle {
collisionPaused: boolean
}
export class ParticleEmitter extends Container {
private pool: Particle[] = []
private cfg: EmitterConfig
@@ -64,13 +55,13 @@ export class ParticleEmitter extends Container {
/** 用于“统一时间”延迟:记录吸附从关闭切换为开启时的发射器时间。 */
private _attractionEnabledAt = 0
private _attractionWasEnabled = false
private _emitterPos: (() => IPointData) | null = null // root 世界坐标(0,0)
/** 活动粒子状态快照(供骨架 / 导出) */
states: ParticleState[] = []
/** 确定性随机(种子>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
@@ -83,6 +74,7 @@ export class ParticleEmitter extends Container {
constructor(config: EmitterConfig, maxParticles = MAX_PARTICLES) {
super()
this.cfg = ensureEmitterConfig(config)
this.trail = new TrailController(this)
this._ensurePool(maxParticles)
this.addChild(this.trailDebugGfx)
}
@@ -123,18 +115,7 @@ export class ParticleEmitter extends Container {
attractionStartWorldX: 0,
attractionStartWorldY: 0,
attractionCurveSide: 1,
trailMesh: null,
trailMeshSignature: '',
trailHistory: [],
trailBones: [],
trailSkinWeights: [],
trailCurrentWidth: 0,
trailCurrentAlpha: 0,
trailCurrentColor: 0xffffff,
trailResourceId: 1,
trailEnabled: false,
trailSampledLength: 100,
trailSampledWidth: 100,
trail: createTrailState(),
resourceId: 1,
colorSample: 0xffffff,
imageFrameIndex: 0,
@@ -143,7 +124,6 @@ export class ParticleEmitter extends Container {
}
}
setEmitterPos(fn: () => IPointData) { this._emitterPos = fn }
setSpawnPathSampler(sampler: ((progress: number) => { x: number; y: number; angle: number } | null) | null, closed = false) {
this.spawnPathSampler = sampler
this.spawnPathClosed = closed
@@ -360,9 +340,9 @@ export class ParticleEmitter extends Container {
const s = p.sprite
const resource = cfg.imageResources.find((item) => item.id === p.resourceId)
if (resource) {
const frameState = this.imageFrameState(resource, p.maxLife - p.life)
const frameState = imageFrameState(resource, p.maxLife - p.life)
p.imageFrameIndex = frameState.index
s.texture = this.imageFrameTexture(resource, p.imageFrameIndex)
s.texture = imageFrameTexture(resource, cfg.texture, p.imageFrameIndex)
s.visible = frameState.visible
}
@@ -412,7 +392,7 @@ export class ParticleEmitter extends Container {
: hexToNumber(colorStart)
s.tint = rgb
s.blendMode = BLEND_MAP[resource?.blend || 'normal'] as any
const trailState = this.updateTrail(p, renderX, renderY, t, s)
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)
@@ -432,265 +412,9 @@ export class ParticleEmitter extends Container {
return states
}
/** 根据网格行列生成拓扑,并为每个顶点绑定相邻两根拖尾骨骼。 */
private ensureTrailMesh(p: Particle) {
const cfg = this.cfg
const trailResource = cfg.trailResources.find((resource) => resource.id === p.trailResourceId) || cfg.trailResources[0]
const activeTrailTexture = trailResource?.texture || Texture.EMPTY
const textureRotation = Number(trailResource?.rotation) || 0
const rows = Math.max(2, Math.min(16, Math.round(cfg.trailGridRows)))
const cols = Math.max(2, Math.min(32, Math.round(cfg.trailGridCols)))
const bones = Math.max(2, Math.min(16, Math.round(trailResource?.boneCount ?? 3)))
const signature = `${rows}:${cols}:${bones}:${trailResource?.id ?? 0}:${textureRotation}`
if (p.trailMesh && p.trailMeshSignature === signature) {
if (p.trailMesh.texture !== activeTrailTexture) p.trailMesh.texture = activeTrailTexture
return p.trailMesh
}
if (p.trailMesh) {
this.removeChild(p.trailMesh)
p.trailMesh.destroy()
}
// 拓扑或骨骼数量变化后丢弃旧轨迹,避免新权重继续套用旧骨骼位置产生偏移。
if (p.trailMeshSignature && p.trailMeshSignature !== signature) {
p.trailHistory.length = 0
p.trailBones.length = 0
}
const vertices = new Float32Array(rows * cols * 2)
const uvs = new Float32Array(rows * cols * 2)
const indices: number[] = []
const weights: { boneA: number; boneB: number; weight: number }[] = []
for (let col = 0; col < cols; col++) {
const columnT = col / (cols - 1)
const bonePosition = columnT * (bones - 1)
const boneA = Math.min(bones - 1, Math.floor(bonePosition))
const boneB = Math.min(bones - 1, boneA + 1)
const weight = bonePosition - boneA
for (let row = 0; row < rows; row++) {
const vertex = col * rows + row
// 骨骼链第 0 列是粒子头部;拖尾图片左侧为头。资源旋转绕 UV 中心重新绑定。
const sourceU = columnT
const sourceV = row / (rows - 1)
const angle = textureRotation * Math.PI / 180
const cos = Math.cos(angle), sin = Math.sin(angle)
uvs[vertex * 2] = 0.5 + cos * (sourceU - 0.5) - sin * (sourceV - 0.5)
uvs[vertex * 2 + 1] = 0.5 + sin * (sourceU - 0.5) + cos * (sourceV - 0.5)
weights.push({ boneA, boneB, weight })
}
}
for (let col = 0; col < cols - 1; col++) {
for (let row = 0; row < rows - 1; row++) {
const a = col * rows + row
const b = (col + 1) * rows + row
const c = a + 1
const d = b + 1
indices.push(a, b, c, b, d, c)
}
}
const mesh = new SimpleMesh(activeTrailTexture, vertices as any, uvs as any, new Uint16Array(indices) as any)
mesh.visible = false
mesh.blendMode = BLEND_MAP.normal as any
// 拖尾网格必须在粒子图片下方绘制。
this.addChildAt(mesh, Math.max(0, this.getChildIndex(p.sprite)))
p.trailMesh = mesh
p.trailMeshSignature = signature
p.trailSkinWeights = weights
return mesh
}
private appendTrailPoint(p: Particle, x: number, y: number, configuredLength: number) {
const first = p.trailHistory[0]
if (!first || Math.hypot(first.x - x, first.y - y) > 0.05) p.trailHistory.unshift({ x, y })
else { first.x = x; first.y = y }
// 保留略长于最大配置长度的轨迹,生命周期曲线从短变长时不会突然断裂。
const keepDistance = Math.max(1, configuredLength) * 1.5
let distance = 0
let keepCount = p.trailHistory.length
for (let i = 1; i < p.trailHistory.length; i++) {
distance += Math.hypot(p.trailHistory[i].x - p.trailHistory[i - 1].x, p.trailHistory[i].y - p.trailHistory[i - 1].y)
if (distance >= keepDistance || i >= 239) { keepCount = i + 1; break }
}
if (p.trailHistory.length > keepCount) p.trailHistory.length = keepCount
}
/** 沿历史轨迹等距生成同级拖尾骨骼;第 0 根骨骼位于粒子头部。 */
private sampleTrailBones(p: Particle, length: number, boneCount: number) {
const count = Math.max(2, Math.min(16, Math.round(boneCount)))
const history = p.trailHistory
const result: TrailBoneState[] = []
if (!history.length) return result
let availableLength = 0
for (let index = 1; index < history.length; index++) {
availableLength += Math.hypot(history[index].x - history[index - 1].x, history[index].y - history[index - 1].y)
}
// 轨迹不足完整长度时,不把后方骨骼堆叠在最旧采样点;所有骨骼始终在
// 当前有效轨迹内等距插值,并随可用长度增加逐渐展开到配置长度。
const sampledLength = Math.min(Math.max(0, length), availableLength)
for (let boneIndex = 0; boneIndex < count; boneIndex++) {
const targetDistance = sampledLength * boneIndex / (count - 1)
let traversed = 0
let point = history[history.length - 1]
for (let i = 1; i < history.length; i++) {
const previous = history[i - 1]
const next = history[i]
const segment = Math.hypot(next.x - previous.x, next.y - previous.y)
if (traversed + segment >= targetDistance && segment > 0.0001) {
const amount = (targetDistance - traversed) / segment
point = { x: lerp(previous.x, next.x, amount), y: lerp(previous.y, next.y, amount) }
break
}
traversed += segment
}
result.push({ boneName: `${p.boneName}_trail_${boneIndex}`, x: point.x, y: point.y, rotation: 0 })
}
for (let i = 0; i < result.length; i++) {
const next = result[Math.min(result.length - 1, i + 1)]
const previous = result[Math.max(0, i - 1)]
const dx = i < result.length - 1 ? next.x - result[i].x : result[i].x - previous.x
const dy = i < result.length - 1 ? next.y - result[i].y : result[i].y - previous.y
result[i].rotation = Math.atan2(dy, dx) * 180 / Math.PI
}
if (sampledLength + 0.0001 < Math.max(0, length) && result.length > 1) {
// 拖尾仍在展开阶段时,位置已经在当前有效轨迹中等距插值;旋转也在
// 头尾切线之间使用最短角度插值,避免同级骨骼驱动的网格发生方向折跳。
const startRotation = result[0].rotation
const endRotation = result[result.length - 1].rotation
const shortestDelta = ((endRotation - startRotation + 540) % 360) - 180
for (let index = 0; index < result.length; index++) {
result[index].rotation = startRotation + shortestDelta * index / (result.length - 1)
}
}
return result
}
/** 由骨骼位置和顶点权重完成 CPU 蒙皮,更新 Pixi 网格。 */
private skinTrailMesh(p: Particle, state: TrailState) {
const mesh = this.ensureTrailMesh(p)
const cfg = this.cfg
const trailResource = cfg.trailResources.find((resource) => resource.id === state.resourceId) || cfg.trailResources[0]
if (!trailResource?.texture || state.bones.length < 2) { mesh.visible = false; return }
p.trailResourceId = trailResource.id
if (mesh.texture !== trailResource.texture) mesh.texture = trailResource.texture
const rows = Math.max(2, Math.min(16, Math.round(cfg.trailGridRows)))
const cols = Math.max(2, Math.min(32, Math.round(cfg.trailGridCols)))
const vertices = mesh.vertices as unknown as Float32Array
const centers: { x: number; y: number }[] = []
for (let col = 0; col < cols; col++) {
const skin = p.trailSkinWeights[col * rows]
const a = state.bones[Math.min(state.bones.length - 1, skin.boneA)]
const b = state.bones[Math.min(state.bones.length - 1, skin.boneB)]
centers.push({ x: lerp(a.x, b.x, skin.weight), y: lerp(a.y, b.y, skin.weight) })
}
for (let col = 0; col < cols; col++) {
const before = centers[Math.max(0, col - 1)]
const after = centers[Math.min(cols - 1, col + 1)]
const dx = after.x - before.x
const dy = after.y - before.y
const tangentLength = Math.max(0.0001, Math.hypot(dx, dy))
const normalX = -dy / tangentLength
const normalY = dx / tangentLength
const columnT = col / (cols - 1)
const shape = trailResource.shapeEnabled ? Math.max(0, curveAt(trailResource.shapeCurve, columnT)) : 1
for (let row = 0; row < rows; row++) {
const rowT = row / (rows - 1) - 0.5
const offset = rowT * state.width * shape
const vertex = (col * rows + row) * 2
vertices[vertex] = centers[col].x + normalX * offset
vertices[vertex + 1] = centers[col].y + normalY * offset
}
}
mesh.tint = state.colorHex
mesh.alpha = state.alpha
mesh.blendMode = BLEND_MAP[trailResource.blend] as any
mesh.visible = Math.hypot(centers[0].x - centers[cols - 1].x, centers[0].y - centers[cols - 1].y) > 0.5 && state.alpha > 0
}
private updateTrail(p: Particle, renderX: number, renderY: number, lifeT: number, sprite: Sprite): TrailState | undefined {
const cfg = this.cfg
if (!cfg.trail || !p.trailEnabled || p.trailSampledLength <= 0 || p.trailSampledWidth <= 0) {
p.trailHistory.length = 0
p.trailBones.length = 0
if (p.trailMesh) p.trailMesh.visible = false
return undefined
}
// 先确认骨骼/网格签名;发生结构变化时会在记录新轨迹前完成清理和重新绑定。
this.ensureTrailMesh(p)
const trailResource = cfg.trailResources.find((resource) => resource.id === p.trailResourceId) || cfg.trailResources[0]
this.appendTrailPoint(p, renderX, renderY, p.trailSampledLength)
const lengthMultiplier = trailResource?.lifeLengthEnabled ? Math.max(0, curveAt(trailResource.lifeLengthCurve, lifeT)) : 1
const length = Math.max(0, p.trailSampledLength) * lengthMultiplier
p.trailBones = this.sampleTrailBones(p, length, trailResource?.boneCount ?? 3)
// 首骨骼必须与粒子最终渲染位置完全一致,避免采样/重绑时出现亚像素脱节。
if (p.trailBones[0]) {
p.trailBones[0].x = renderX
p.trailBones[0].y = renderY
}
const particleWidthScale = trailResource?.widthMode === 'particle'
? (Math.abs(sprite.scale.x) + Math.abs(sprite.scale.y)) * 0.5
: 1
p.trailCurrentWidth = Math.max(0, p.trailSampledWidth) * particleWidthScale
p.trailCurrentAlpha = trailResource?.alphaMode === 'particle'
? sprite.alpha
: trailResource?.alphaMode === 'curve'
? curveAt(trailResource.alphaCurve, lifeT)
: trailResource?.alpha ?? 1
p.trailCurrentColor = trailResource?.colorMode === 'particle'
? sprite.tint as number
: trailResource?.colorMode === 'curve'
? gradientColorAt(trailResource.colorGradient, lifeT)
: hexToNumber(trailResource?.color || '#ffffff')
const state: TrailState = {
bones: p.trailBones.map((bone) => ({ ...bone })),
// 拖尾是一张固定拓扑的加权 Mesh,所有绑定骨骼必须成组存活。
// 历史长度不足时所有骨骼会在当前有效轨迹内等距插值,而不是延迟出生。
activeBoneCount: p.trailBones.length,
width: p.trailCurrentWidth,
alpha: Math.min(1, Math.max(0, p.trailCurrentAlpha)),
colorHex: p.trailCurrentColor,
resourceId: p.trailResourceId,
}
this.skinTrailMesh(p, state)
return state
}
/** 在画布上绘制实际蒙皮网格;仅用于编辑器调试显示,不参与录制或导出。 */
drawTrailMeshDebug(visible: boolean) {
const g = this.trailDebugGfx
g.clear()
if (!visible || !this.cfg.trail) return
const rows = Math.max(2, Math.min(16, Math.round(this.cfg.trailGridRows)))
const cols = Math.max(2, Math.min(32, Math.round(this.cfg.trailGridCols)))
g.lineStyle(1, 0x39d7ff, 0.72)
for (const p of this.pool) {
const mesh = p.trailMesh
if (!mesh?.visible) continue
const vertices = mesh.vertices as unknown as Float32Array
const point = (col: number, row: number) => {
const index = (col * rows + row) * 2
return { x: vertices[index], y: vertices[index + 1] }
}
for (let row = 0; row < rows; row++) {
for (let col = 0; col < cols; col++) {
const current = point(col, row)
if (col === 0) g.moveTo(current.x, current.y)
else g.lineTo(current.x, current.y)
}
}
for (let col = 0; col < cols; col++) {
for (let row = 0; row < rows; row++) {
const current = point(col, row)
if (row === 0) g.moveTo(current.x, current.y)
else g.lineTo(current.x, current.y)
}
}
g.beginFill(0x8cecff, 0.9)
for (let col = 0; col < cols; col++) {
for (let row = 0; row < rows; row++) {
const current = point(col, row)
g.drawCircle(current.x, current.y, 1.6)
}
}
g.endFill()
}
this.trail.drawDebug(this.trailDebugGfx, visible, this.cfg, this.pool.map((p) => p.trail))
}
private emitBurst() {
@@ -743,11 +467,11 @@ export class ParticleEmitter extends Container {
const speed = initialValue(cfg.speedMode, cfg.speedMin, cfg.speedMax, cfg.speedCurve, this.rng())
const dirAng = sampleEmissionVelocityAngle(cfg, origin, this.followSpawnAngle, this.rng)
const p = this.pool[idx]
const resource = this.pickImageResource()
const resource = pickImageResource(cfg, this.rng)
const spawnSerial = this._spawnSerial++
p.spawnId = spawnSerial
// 拖尾资源使用独立的确定性采样,不改变粒子属性原有随机序列。
const trailResource = this.pickTrailResource(seededUnit(cfg.seed, spawnSerial, 0x5bd1e995))
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
@@ -761,32 +485,33 @@ export class ParticleEmitter extends Container {
p.attractionElapsed = 0
p.collisionPaused = false
p.attractionCurveSide = seededUnit(cfg.seed, spawnSerial, 0xa341316c) < 0.5 ? -1 : 1
p.trailHistory.length = 0
p.trailBones.length = 0
p.trailCurrentWidth = 0
p.trailCurrentAlpha = 0
p.trailCurrentColor = 0xffffff
p.trailResourceId = trailResource.id
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.trailSampledLength = sampleRange(trailResource.lengthMode, trailResource.length, trailResource.lengthMin, trailResource.lengthMax, 0x165667b1)
p.trailSampledWidth = sampleRange(trailResource.widthSizeMode, trailResource.width, trailResource.widthMin, trailResource.widthMax, 0xd3a2646c)
p.trailEnabled = cfg.trail && (forceSingleTrail || ratioSample < ratio) && p.trailSampledLength > 0 && p.trailSampledWidth > 0
if (p.trailMesh) p.trailMesh.visible = false
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.trailEnabled) p.trailHistory.push({ x: ox, 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
@@ -814,98 +539,27 @@ export class ParticleEmitter extends Container {
p.colorSample = resource.colorMode === 'random'
? gradientColorAt(resource.colorGradient, seededUnit(cfg.seed, spawnSerial, 0x9e3779b9))
: hexToNumber(resource.colorStart)
const initialFrame = this.imageFrameState(resource, 0)
const initialFrame = imageFrameState(resource, 0)
p.imageFrameIndex = initialFrame.index
p.sprite.texture = this.imageFrameTexture(resource, 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.visible = true
}
private pickImageResource(): ParticleImageResource {
const resources = this.cfg.imageResources.filter((resource) => resource.texture || resource.sequenceFrames.some((frame) => frame.texture))
if (!resources.length) return {
id: 1, texture: this.cfg.texture, textureName: 'star', textureFolder: '',
previewUrl: '/images/star.png', imageMode: 'fixed', sequencePlayback: 'loop-forward', sequenceFps: 30, sequenceFrames: [],
weight: 100, locked: false, scale: 1, anchorX: 0.5, anchorY: 0.5,
colorMode: 'fixed', colorStart: '#ffffff', colorEnd: '#ff4d4d', blend: 'normal',
colorGradient: [{ position: 0, color: '#ffffff' }, { position: 1, color: '#ff4d4d' }],
independentAlpha: 'off', alpha: 1, alphaCurveMode: '跟随导出',
alphaCurve: [{ x: 0, y: 0 }, { x: 0.1, y: 1 }, { x: 0.9, y: 1 }, { x: 1, y: 0 }],
}
const total = resources.reduce((sum, resource) => sum + Math.max(0, resource.weight), 0)
if (total <= 0) return resources[Math.floor(this.rng() * resources.length)]
let cursor = this.rng() * total
for (const resource of resources) {
cursor -= Math.max(0, resource.weight)
if (cursor <= 0) return resource
}
return resources[resources.length - 1]
}
private imageFrameState(resource: ParticleImageResource, age: number) {
if (resource.imageMode !== 'sequence' || resource.sequenceFrames.length <= 1) return { index: 0, visible: true }
const fps = Math.min(120, Math.max(1, Number(resource.sequenceFps) || 30))
const rawIndex = Math.max(0, Math.floor(Math.max(0, age) * fps))
const lastIndex = resource.sequenceFrames.length - 1
if (resource.sequencePlayback === 'once-hide') {
return { index: Math.min(lastIndex, rawIndex), visible: rawIndex <= lastIndex }
}
if (resource.sequencePlayback === 'once-hold') return { index: Math.min(lastIndex, rawIndex), visible: true }
if (resource.sequencePlayback === 'loop-reverse') return { index: lastIndex - rawIndex % resource.sequenceFrames.length, visible: true }
return { index: rawIndex % resource.sequenceFrames.length, visible: true }
}
private imageFrameTexture(resource: ParticleImageResource, frameIndex: number) {
if (resource.imageMode === 'sequence' && resource.sequenceFrames.length) {
return resource.sequenceFrames[Math.max(0, Math.min(resource.sequenceFrames.length - 1, frameIndex))]?.texture
|| resource.texture
|| this.cfg.texture
}
return resource.texture || this.cfg.texture
}
private pickTrailResource(sample: number): TrailImageResource {
const resources = this.cfg.trailResources.filter((resource) => resource.texture)
if (!resources.length) return this.cfg.trailResources[0] || {
id: 1, texture: null, textureName: 'trail', previewUrl: '/images/trail.png', weight: 100, locked: false,
rotation: 0, colorMode: 'particle', color: '#ffffff',
colorGradient: [{ position: 0, color: '#ffffff' }, { position: 1, color: '#ffffff' }],
blend: 'normal',
alphaMode: 'particle', alpha: 1, alphaCurve: [{ x: 0, y: 1 }, { x: 1, y: 1 }],
boneCount: 3, trailRatio: 100,
lengthMode: 'fixed', length: 100, lengthMin: 100, lengthMax: 100,
widthSizeMode: 'fixed', width: 100, widthMin: 100, widthMax: 100, widthMode: 'particle',
lifeLengthEnabled: false, lifeLengthCurve: [{ x: 0, y: 1 }, { x: 1, y: 1 }],
shapeEnabled: false, shapeCurve: [{ x: 0, y: 1 }, { x: 1, y: 1 }],
}
const total = resources.reduce((sum, resource) => sum + Math.max(0, resource.weight), 0)
if (total <= 0) return resources[Math.min(resources.length - 1, Math.floor(sample * resources.length))]
let cursor = sample * total
for (const resource of resources) {
cursor -= Math.max(0, resource.weight)
if (cursor <= 0) return resource
}
return resources[resources.length - 1]
}
private kill(i: number) {
const p = this.pool[i]
p.active = false
p.sprite.visible = false
p.trailHistory.length = 0
p.trailBones.length = 0
if (p.trailMesh) p.trailMesh.visible = false
resetTrailState(p.trail)
}
clear() {
for (const p of this.pool) {
p.active = false
p.sprite.visible = false
p.trailHistory.length = 0
p.trailBones.length = 0
if (p.trailMesh) p.trailMesh.visible = false
resetTrailState(p.trail)
}
}
@@ -926,17 +580,17 @@ export class ParticleEmitter extends Container {
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.trailBones.length
trailState: this.cfg.trail && p.trail.bones.length
? {
bones: p.trailBones.map((bone) => {
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.trailBones.length,
width: p.trailCurrentWidth,
alpha: p.trailCurrentAlpha,
colorHex: p.trailCurrentColor,
resourceId: p.trailResourceId,
activeBoneCount: p.trail.bones.length,
width: p.trail.currentWidth,
alpha: p.trail.currentAlpha,
colorHex: p.trail.currentColor,
resourceId: p.trail.resourceId,
}
: undefined,
})
@@ -965,7 +619,7 @@ export class ParticleEmitter extends Container {
if (!st) {
p.active = false
p.sprite.visible = false
if (p.trailMesh) p.trailMesh.visible = false
if (p.trail.mesh) p.trail.mesh.visible = false
continue
}
p.active = st.active
@@ -980,18 +634,14 @@ export class ParticleEmitter extends Container {
const resource = this.cfg.imageResources.find((item) => item.id === st.resourceId)
if (resource) {
p.imageFrameIndex = st.imageFrameIndex ?? 0
s.texture = this.imageFrameTexture(resource, p.imageFrameIndex)
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) {
p.trailResourceId = st.trailState.resourceId ?? this.cfg.trailResources[0]?.id ?? 1
p.trailBones = st.trailState.bones.map((bone) => ({ ...bone }))
p.trailCurrentWidth = st.trailState.width
p.trailCurrentAlpha = st.trailState.alpha
this.skinTrailMesh(p, st.trailState)
} else if (p.trailMesh) p.trailMesh.visible = false
this.trail.applyState(p.trail, this.cfg, p.sprite, st.trailState)
} else if (p.trail.mesh) p.trail.mesh.visible = false
}
}
@@ -1000,9 +650,7 @@ export class ParticleEmitter extends Container {
for (const p of this.pool) {
p.active = false
p.sprite.visible = false
p.trailHistory.length = 0
p.trailBones.length = 0
if (p.trailMesh) p.trailMesh.visible = false
resetTrailState(p.trail)
}
this.accumulator = 0
this.burstTimer = 0
@@ -1030,4 +678,3 @@ export class ParticleEmitter extends Container {
this.rng = mulberry32(this.cfg.seed >>> 0)
}
}