代码拆分
This commit is contained in:
1 parent
8bf16fe09f
commit
ee72d09b23
20 files changed
+2760
-2450
No files matched your search
+45
-398
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user