阶段五完成
This commit is contained in:
1 parent
4e2e123269
commit
f2fe653c04
6 files changed
+1423
-60
No files matched your search
+532
-7
@@ -1,4 +1,4 @@
|
||||
import { Container, Sprite, Texture, IPointData } from 'pixi.js'
|
||||
import { Container, Sprite, Texture, IPointData, SimpleMesh, Graphics } from 'pixi.js'
|
||||
|
||||
/** 发射模式 */
|
||||
export type EmitMode = 'stream' | 'burst'
|
||||
@@ -39,6 +39,32 @@ export interface ParticleImageResource {
|
||||
alphaCurve: CurvePoint[]
|
||||
}
|
||||
|
||||
export interface TrailImageResource {
|
||||
id: number
|
||||
texture: Texture<any> | null
|
||||
textureName: string
|
||||
previewUrl: string
|
||||
weight: number
|
||||
locked: boolean
|
||||
/** 图片默认方向,修改后重新生成 UV 并绑定蒙皮 */
|
||||
rotation: number
|
||||
colorMode: 'particle' | 'fixed' | 'curve'
|
||||
color: string
|
||||
colorGradient: ColorGradientStop[]
|
||||
blend: Blend
|
||||
alphaMode: 'particle' | 'fixed' | 'curve'
|
||||
alpha: number
|
||||
alphaCurve: CurvePoint[]
|
||||
boneCount: number
|
||||
length: number
|
||||
width: number
|
||||
widthMode: 'particle' | 'fixed'
|
||||
lifeLengthEnabled: boolean
|
||||
lifeLengthCurve: CurvePoint[]
|
||||
shapeEnabled: boolean
|
||||
shapeCurve: CurvePoint[]
|
||||
}
|
||||
|
||||
/** 粒子配置(引用保持,面板改字段即实时生效) */
|
||||
export interface EmitterConfig {
|
||||
texture: Texture<any>
|
||||
@@ -196,6 +222,32 @@ export interface EmitterConfig {
|
||||
attractionPath: 'linear' | 'curve'
|
||||
/** 开启拖尾 */
|
||||
trail: boolean
|
||||
trailConfigVersion: number
|
||||
trailResources: TrailImageResource[]
|
||||
trailResourceVersion: number
|
||||
/** 拖尾使用独立图片和独立网格蒙皮 */
|
||||
/** @deprecated 旧版单拖尾资源字段,仅用于配置迁移 */
|
||||
trailTexture: Texture<any> | null
|
||||
trailTextureName: string
|
||||
trailPreviewUrl: string
|
||||
trailColorMode: 'particle' | 'fixed' | 'curve'
|
||||
trailColor: string
|
||||
trailColorGradient: ColorGradientStop[]
|
||||
trailAlphaMode: 'particle' | 'fixed' | 'curve'
|
||||
trailAlpha: number
|
||||
trailAlphaCurve: CurvePoint[]
|
||||
/** 每个粒子的拖尾骨骼链数量 */
|
||||
trailBoneCount: number
|
||||
trailLength: number
|
||||
trailWidth: number
|
||||
trailWidthMode: 'particle' | 'fixed'
|
||||
trailLifeLengthEnabled: boolean
|
||||
trailLifeLengthCurve: CurvePoint[]
|
||||
trailShapeEnabled: boolean
|
||||
trailShapeCurve: CurvePoint[]
|
||||
/** 蒙皮网格拓扑尺寸 */
|
||||
trailGridRows: number
|
||||
trailGridCols: number
|
||||
/** 开启碰撞(预留) */
|
||||
collision: boolean
|
||||
/** 开启路径跟随(预留) */
|
||||
@@ -214,9 +266,26 @@ export interface ParticleState {
|
||||
alpha: number
|
||||
colorHex: number
|
||||
resourceId?: number
|
||||
/** 拖尾拥有独立骨骼链和蒙皮状态,录制/回放时一并保存。 */
|
||||
trailState?: TrailState
|
||||
active: boolean
|
||||
}
|
||||
|
||||
export interface TrailBoneState {
|
||||
boneName: string
|
||||
x: number
|
||||
y: number
|
||||
rotation: number
|
||||
}
|
||||
|
||||
export interface TrailState {
|
||||
bones: TrailBoneState[]
|
||||
width: number
|
||||
alpha: number
|
||||
colorHex: number
|
||||
resourceId?: number
|
||||
}
|
||||
|
||||
export function defaultConfig(): EmitterConfig {
|
||||
return {
|
||||
texture: null as unknown as Texture,
|
||||
@@ -354,6 +423,51 @@ export function defaultConfig(): EmitterConfig {
|
||||
attractionDelayMode: 'particle',
|
||||
attractionPath: 'curve',
|
||||
trail: false,
|
||||
trailConfigVersion: 3,
|
||||
trailResources: [{
|
||||
id: 1,
|
||||
texture: null,
|
||||
textureName: 'trail',
|
||||
previewUrl: '/trails/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,
|
||||
length: 100,
|
||||
width: 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 }],
|
||||
}],
|
||||
trailResourceVersion: 5,
|
||||
trailTexture: null,
|
||||
trailTextureName: 'trail',
|
||||
trailPreviewUrl: '/trails/trail.png',
|
||||
trailColorMode: 'particle',
|
||||
trailColor: '#ffffff',
|
||||
trailColorGradient: [{ position: 0, color: '#ffffff' }, { position: 1, color: '#ffffff' }],
|
||||
trailAlphaMode: 'particle',
|
||||
trailAlpha: 1,
|
||||
trailAlphaCurve: [{ x: 0, y: 1 }, { x: 1, y: 1 }],
|
||||
trailBoneCount: 3,
|
||||
trailLength: 100,
|
||||
trailWidth: 100,
|
||||
trailWidthMode: 'particle',
|
||||
trailLifeLengthEnabled: false,
|
||||
trailLifeLengthCurve: [{ x: 0, y: 1 }, { x: 1, y: 1 }],
|
||||
trailShapeEnabled: false,
|
||||
trailShapeCurve: [{ x: 0, y: 1 }, { x: 1, y: 1 }],
|
||||
trailGridRows: 4,
|
||||
trailGridCols: 4,
|
||||
collision: false,
|
||||
pathFollow: false,
|
||||
}
|
||||
@@ -371,6 +485,12 @@ export function ensureEmitterConfig(config: EmitterConfig): EmitterConfig {
|
||||
const legacyWindY = Number(target.windY)
|
||||
const needsForceFieldMigration = target.forceFalloff == null
|
||||
const legacyForceStrength = Number(target.forceStrength)
|
||||
const needsTrailCurveMigration = target.trailConfigVersion !== 3
|
||||
const needsTrailResourceMigration = !Array.isArray(target.trailResources)
|
||||
const needsTrailResourceStyleMigration = target.trailResourceVersion !== 5
|
||||
const legacyTrailTexture = target.trailTexture as Texture<any> | null | undefined
|
||||
const legacyTrailTextureName = String(target.trailTextureName || 'trail')
|
||||
const legacyTrailPreviewUrl = String(target.trailPreviewUrl || '/trails/trail.png')
|
||||
for (const [key, value] of Object.entries(defaults)) {
|
||||
if (target[key] !== undefined && target[key] !== null) continue
|
||||
target[key] = Array.isArray(value)
|
||||
@@ -436,6 +556,80 @@ export function ensureEmitterConfig(config: EmitterConfig): EmitterConfig {
|
||||
if (needsForceFieldMigration) {
|
||||
config.forceStrength = Number.isFinite(legacyForceStrength) && legacyForceStrength !== 0 ? legacyForceStrength : 10
|
||||
}
|
||||
if (needsTrailCurveMigration) {
|
||||
config.trailConfigVersion = 3
|
||||
config.trailAlphaCurve = [{ x: 0, y: 1 }, { x: 1, y: 1 }]
|
||||
config.trailLifeLengthCurve = [{ x: 0, y: 1 }, { x: 1, y: 1 }]
|
||||
config.trailShapeCurve = [{ x: 0, y: 1 }, { x: 1, y: 1 }]
|
||||
config.trailGridRows = 4
|
||||
config.trailGridCols = 4
|
||||
}
|
||||
if (needsTrailResourceMigration || !config.trailResources.length) {
|
||||
config.trailResources = [{
|
||||
id: 1,
|
||||
texture: legacyTrailTexture || null,
|
||||
textureName: legacyTrailTextureName,
|
||||
previewUrl: legacyTrailPreviewUrl,
|
||||
weight: 100,
|
||||
locked: false,
|
||||
rotation: 0,
|
||||
colorMode: config.trailColorMode,
|
||||
color: config.trailColor,
|
||||
colorGradient: config.trailColorGradient.map((stop) => ({ ...stop })),
|
||||
blend: 'normal',
|
||||
alphaMode: config.trailAlphaMode,
|
||||
alpha: config.trailAlpha,
|
||||
alphaCurve: config.trailAlphaCurve.map((point) => ({ ...point })),
|
||||
boneCount: config.trailBoneCount,
|
||||
length: config.trailLength,
|
||||
width: config.trailWidth,
|
||||
widthMode: config.trailWidthMode,
|
||||
lifeLengthEnabled: config.trailLifeLengthEnabled,
|
||||
lifeLengthCurve: config.trailLifeLengthCurve.map((point) => ({ ...point })),
|
||||
shapeEnabled: config.trailShapeEnabled,
|
||||
shapeCurve: config.trailShapeCurve.map((point) => ({ ...point })),
|
||||
}]
|
||||
}
|
||||
for (const resource of config.trailResources) {
|
||||
if (!resource.textureName) resource.textureName = 'trail'
|
||||
if (!resource.previewUrl) resource.previewUrl = '/trails/trail.png'
|
||||
if (!Number.isFinite(resource.weight)) resource.weight = 100
|
||||
if (typeof resource.locked !== 'boolean') resource.locked = false
|
||||
if (!Number.isFinite(resource.rotation)) resource.rotation = 0
|
||||
if (!['particle', 'fixed', 'curve'].includes(resource.colorMode)) resource.colorMode = config.trailColorMode || 'particle'
|
||||
if (!resource.color) resource.color = config.trailColor || '#ffffff'
|
||||
if (!Array.isArray(resource.colorGradient) || resource.colorGradient.length < 2) {
|
||||
resource.colorGradient = (config.trailColorGradient || [{ position: 0, color: '#ffffff' }, { position: 1, color: '#ffffff' }]).map((stop) => ({ ...stop }))
|
||||
}
|
||||
if (!Object.prototype.hasOwnProperty.call(BLEND_MAP, resource.blend)) resource.blend = 'normal'
|
||||
if (!['particle', 'fixed', 'curve'].includes(resource.alphaMode)) resource.alphaMode = config.trailAlphaMode || 'particle'
|
||||
if (!Number.isFinite(resource.alpha)) resource.alpha = Number.isFinite(config.trailAlpha) ? config.trailAlpha : 1
|
||||
if (!Array.isArray(resource.alphaCurve) || resource.alphaCurve.length < 2) {
|
||||
resource.alphaCurve = (config.trailAlphaCurve || [{ x: 0, y: 1 }, { x: 1, y: 1 }]).map((point) => ({ ...point }))
|
||||
}
|
||||
if (!Number.isFinite(resource.boneCount)) resource.boneCount = Number.isFinite(config.trailBoneCount) ? config.trailBoneCount : 3
|
||||
if (!Number.isFinite(resource.length)) resource.length = Number.isFinite(config.trailLength) ? config.trailLength : 100
|
||||
if (!Number.isFinite(resource.width)) resource.width = Number.isFinite(config.trailWidth) ? config.trailWidth : 100
|
||||
if (!['particle', 'fixed'].includes(resource.widthMode)) resource.widthMode = config.trailWidthMode || 'particle'
|
||||
if (typeof resource.lifeLengthEnabled !== 'boolean') resource.lifeLengthEnabled = Boolean(config.trailLifeLengthEnabled)
|
||||
if (!Array.isArray(resource.lifeLengthCurve) || resource.lifeLengthCurve.length < 2) {
|
||||
resource.lifeLengthCurve = (config.trailLifeLengthCurve || [{ x: 0, y: 1 }, { x: 1, y: 1 }]).map((point) => ({ ...point }))
|
||||
}
|
||||
if (typeof resource.shapeEnabled !== 'boolean') resource.shapeEnabled = Boolean(config.trailShapeEnabled)
|
||||
if (!Array.isArray(resource.shapeCurve) || resource.shapeCurve.length < 2) {
|
||||
resource.shapeCurve = (config.trailShapeCurve || [{ x: 0, y: 1 }, { x: 1, y: 1 }]).map((point) => ({ ...point }))
|
||||
}
|
||||
}
|
||||
if (config.trailResources.length === 1) {
|
||||
config.trailResources[0].weight = 100
|
||||
config.trailResources[0].locked = false
|
||||
}
|
||||
if (needsTrailResourceStyleMigration) {
|
||||
for (const resource of config.trailResources) {
|
||||
resource.alphaCurve = resource.alphaCurve?.length >= 2 ? resource.alphaCurve : [{ x: 0, y: 1 }, { x: 1, y: 1 }]
|
||||
}
|
||||
}
|
||||
config.trailResourceVersion = 5
|
||||
return config
|
||||
}
|
||||
|
||||
@@ -461,6 +655,15 @@ 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
|
||||
resourceId: number
|
||||
}
|
||||
|
||||
@@ -473,6 +676,7 @@ export class ParticleEmitter extends Container {
|
||||
private cfg: EmitterConfig
|
||||
private accumulator = 0
|
||||
private burstTimer = 0
|
||||
private _spawnSerial = 0
|
||||
/** 发射器累计运行时间(秒) — 非响应式,避免污染 config */
|
||||
private _elapsed = 0
|
||||
/** 用于“统一时间”延迟:记录吸附从关闭切换为开启时的发射器时间。 */
|
||||
@@ -484,11 +688,13 @@ export class ParticleEmitter extends Container {
|
||||
/** 确定性随机(种子>0 时可复现) */
|
||||
private rng: () => number = Math.random
|
||||
private _lastSeed = -1
|
||||
private trailDebugGfx = new Graphics()
|
||||
|
||||
constructor(config: EmitterConfig, maxParticles = MAX_PARTICLES) {
|
||||
super()
|
||||
this.cfg = ensureEmitterConfig(config)
|
||||
this._ensurePool(maxParticles)
|
||||
this.addChild(this.trailDebugGfx)
|
||||
}
|
||||
|
||||
/** 仅在种子变化时重建随机源;种子不变时让序列持续推进,保证"确定性随机序列"语义 */
|
||||
@@ -521,6 +727,15 @@ 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,
|
||||
resourceId: 1,
|
||||
})
|
||||
}
|
||||
@@ -536,7 +751,7 @@ export class ParticleEmitter extends Container {
|
||||
cfg.lifeMode == null || cfg.speedMode == null || cfg.scaleMode == null ||
|
||||
cfg.initialRotationMode == null || cfg.speedOverLifeEnabled == null ||
|
||||
cfg.scaleOverLifeEnabled == null || cfg.rotationOverLifeEnabled == null ||
|
||||
!Array.isArray(cfg.scaleOverLifeCurve) || cfg.attraction == null
|
||||
!Array.isArray(cfg.scaleOverLifeCurve) || cfg.attraction == null || cfg.trailBoneCount == null
|
||||
) ensureEmitterConfig(cfg)
|
||||
this.reseed() // 种子变化即重建随机源
|
||||
// 累计发射时间
|
||||
@@ -725,12 +940,254 @@ export class ParticleEmitter extends Container {
|
||||
: hexToNumber(colorStart)
|
||||
s.tint = rgb
|
||||
s.blendMode = BLEND_MAP[resource?.blend || 'normal'] as any
|
||||
states.push({ boneName: p.boneName, x: renderX, y: renderY, rotation: p.rotation, scaleX: s.scale.x, scaleY: s.scale.y, alpha: s.alpha, colorHex: rgb, resourceId: p.resourceId, active: true })
|
||||
const trailState = this.updateTrail(p, renderX, renderY, t, s)
|
||||
states.push({ boneName: p.boneName, x: renderX, y: renderY, rotation: p.rotation, scaleX: s.scale.x, scaleY: s.scale.y, alpha: s.alpha, colorHex: rgb, resourceId: p.resourceId, trailState, active: true })
|
||||
}
|
||||
this.states = states
|
||||
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
|
||||
for (let boneIndex = 0; boneIndex < count; boneIndex++) {
|
||||
const targetDistance = length * 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
|
||||
}
|
||||
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.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, trailResource?.length ?? 100)
|
||||
const lengthMultiplier = trailResource?.lifeLengthEnabled ? Math.max(0, curveAt(trailResource.lifeLengthCurve, lifeT)) : 1
|
||||
const length = Math.max(0, trailResource?.length ?? 100) * 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, trailResource?.width ?? 100) * 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 })),
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
private emitBurst() {
|
||||
for (let i = 0; i < this.cfg.burstCount; i++) this.spawnOne()
|
||||
}
|
||||
@@ -770,6 +1227,8 @@ export class ParticleEmitter extends Container {
|
||||
}
|
||||
const p = this.pool[idx]
|
||||
const resource = this.pickImageResource()
|
||||
// 拖尾资源使用独立的确定性采样,不改变粒子属性原有随机序列。
|
||||
const trailResource = this.pickTrailResource(seededUnit(cfg.seed, this._spawnSerial++, 0x5bd1e995))
|
||||
// 噪声相位由全局随机种子和粒子池编号确定,同一配置重复录制得到相同轨迹。
|
||||
p.noisePhaseX = seededUnit(cfg.seed, idx, 0x68bc21eb) * Math.PI * 2
|
||||
p.noisePhaseY = seededUnit(cfg.seed, idx, 0x02e5be93) * Math.PI * 2
|
||||
@@ -782,6 +1241,13 @@ export class ParticleEmitter extends Container {
|
||||
p.attractionActive = false
|
||||
p.attractionElapsed = 0
|
||||
p.attractionCurveSide = seededUnit(cfg.seed, idx, 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
|
||||
if (p.trailMesh) p.trailMesh.visible = false
|
||||
p.active = true; p.life = life; p.maxLife = life
|
||||
p.x = ox; p.y = oy
|
||||
p.vx = Math.cos(dirAng) * speed
|
||||
@@ -835,14 +1301,45 @@ export class ParticleEmitter extends Container {
|
||||
return resources[resources.length - 1]
|
||||
}
|
||||
|
||||
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: '/trails/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, length: 100, width: 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
|
||||
}
|
||||
|
||||
clear() {
|
||||
for (const p of this.pool) { p.active = false; p.sprite.visible = false }
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
get activeCount() {
|
||||
@@ -857,9 +1354,19 @@ export class ParticleEmitter extends Container {
|
||||
for (const p of this.pool) {
|
||||
if (!p.active) continue
|
||||
out.push({
|
||||
boneName: p.boneName, x: p.x, y: p.y, rotation: p.rotation,
|
||||
// 拖尾记录的是最终渲染轨迹,因此粒子本体也必须记录 sprite 坐标(包含移动噪声等渲染偏移)。
|
||||
boneName: p.boneName, x: p.sprite.x, y: p.sprite.y, rotation: p.rotation,
|
||||
scaleX: p.sprite.scale.x, scaleY: p.sprite.scale.y,
|
||||
alpha: p.sprite.alpha, colorHex: p.sprite.tint as number, resourceId: p.resourceId, active: true,
|
||||
trailState: this.cfg.trail && p.trailBones.length
|
||||
? {
|
||||
bones: p.trailBones.map((bone) => ({ ...bone })),
|
||||
width: p.trailCurrentWidth,
|
||||
alpha: p.trailCurrentAlpha,
|
||||
colorHex: p.trailCurrentColor,
|
||||
resourceId: p.trailResourceId,
|
||||
}
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
return out
|
||||
@@ -871,7 +1378,11 @@ export class ParticleEmitter extends Container {
|
||||
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.sprite.visible = false; continue }
|
||||
if (!st) {
|
||||
p.sprite.visible = false
|
||||
if (p.trailMesh) p.trailMesh.visible = false
|
||||
continue
|
||||
}
|
||||
const s = p.sprite
|
||||
s.visible = true
|
||||
s.x = st.x; s.y = st.y
|
||||
@@ -887,14 +1398,28 @@ export class ParticleEmitter extends Container {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/** 清空所有粒子并重置发射计时 */
|
||||
reset() {
|
||||
for (const p of this.pool) { p.active = false; p.sprite.visible = false }
|
||||
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
|
||||
}
|
||||
this.accumulator = 0
|
||||
this.burstTimer = 0
|
||||
this._spawnSerial = 0
|
||||
this._elapsed = 0
|
||||
this._attractionEnabledAt = 0
|
||||
this._attractionWasEnabled = false
|
||||
|
||||
Reference in new issue
Block a user