完成阶段3-4

This commit is contained in:
tianmo committed 2026-08-28 16:06:28 +08:00
1 parent f0f4104b0c
commit 4e2e123269
5 files changed
+1158 -50

No files matched your search

+275 -29
View File
@@ -115,10 +115,18 @@ export interface EmitterConfig {
rotationOverLifeMode: AttributeMode
rotationOverLifeCurve: CurvePoint[]
// 物理
/** 旧版重力值,保留用于配置迁移 */
gravity: number
gravityMode: 'fixed' | 'random'
gravityMin: number
gravityMax: number
/** 数学角度:0 向右,-90 向屏幕下方 */
gravityDirection: number
/** 生命周期内重力倍率,纵轴范围 0~1 */
gravityCurve: CurvePoint[]
/** 阻尼(速度衰减系数,0=无) */
damping: number
/** 随机种子(确定性随机,0=每次不同) */
/** 全局随机种子;包括 0 在内的任意整数都可确定性复现 */
seed: number
// 颜色
colorStart: string
@@ -149,17 +157,43 @@ export interface EmitterConfig {
// ===== 修改器 (Modifiers) =====
/** 开启移动噪声 */
noise: boolean
/** 噪声影响比例(0~100%) */
noiseAmt: number
/** 粒子偏离原轨迹的最大距离(px) */
noiseRange: number
/** 噪声每秒变化速度 */
noiseSpeed: number
/** 开启重力(由 gravity 控制) */
useGravity: boolean
/** 开启风力 */
wind: boolean
/** 旧版二维风力,保留用于配置迁移 */
windX: number
windY: number
windMode: 'fixed' | 'random'
windMin: number
windMax: number
/** 数学角度:0 向右,-90 向屏幕下方 */
windDirection: number
/** 生命周期内风力倍率,纵轴范围 0~1 */
windCurve: CurvePoint[]
/** 开启力场(吸力/斥力) */
forceField: boolean
forceStrength: number
forceRadius: number
/** 力场中心使用场景全局数学坐标 */
forceCenterX: number
forceCenterY: number
forceFalloff: 'constant' | 'linear' | 'inverse'
/** 开启吸附:粒子在延迟后从当前位置移动到全局吸附点,到达范围后死亡 */
attraction: boolean
attractionCenterX: number
attractionCenterY: number
attractionRadius: number
attractionDuration: number
attractionDelay: number
attractionDelayMode: 'global' | 'particle'
attractionPath: 'linear' | 'curve'
/** 开启拖尾 */
trail: boolean
/** 开启碰撞(预留) */
@@ -269,7 +303,12 @@ export function defaultConfig(): EmitterConfig {
rotationOverLifeEnabled: false,
rotationOverLifeMode: 'random',
rotationOverLifeCurve: [{ x: 0, y: 0 }, { x: 1, y: 1 }],
gravity: 0,
gravity: 980,
gravityMode: 'fixed',
gravityMin: 980,
gravityMax: 980,
gravityDirection: -90,
gravityCurve: [{ x: 0, y: 1 }, { x: 1, y: 1 }],
damping: 0,
seed: 0,
colorStart: '#ffffff',
@@ -288,14 +327,32 @@ export function defaultConfig(): EmitterConfig {
offsetX: 0,
offsetY: 0,
noise: false,
noiseAmt: 0,
useGravity: true,
noiseAmt: 30,
noiseRange: 100,
noiseSpeed: 1,
useGravity: false,
wind: false,
windX: 0,
windY: 0,
windMode: 'fixed',
windMin: 100,
windMax: 100,
windDirection: 0,
windCurve: [{ x: 0, y: 1 }, { x: 1, y: 1 }],
forceField: false,
forceStrength: 0,
forceStrength: 10,
forceRadius: 100,
forceCenterX: 0,
forceCenterY: 0,
forceFalloff: 'linear',
attraction: false,
attractionCenterX: 200,
attractionCenterY: 0,
attractionRadius: 20,
attractionDuration: 0.5,
attractionDelay: 0.1,
attractionDelayMode: 'particle',
attractionPath: 'curve',
trail: false,
collision: false,
pathFollow: false,
@@ -307,6 +364,13 @@ export function ensureEmitterConfig(config: EmitterConfig): EmitterConfig {
const defaults = defaultConfig() as unknown as Record<string, unknown>
const target = config as unknown as Record<string, unknown>
const needsImageWeightMigration = target.imageWeightVersion !== 1
const needsGravityMigration = target.gravityMode == null
const legacyGravity = Number(target.gravity)
const needsWindMigration = target.windMode == null
const legacyWindX = Number(target.windX)
const legacyWindY = Number(target.windY)
const needsForceFieldMigration = target.forceFalloff == null
const legacyForceStrength = Number(target.forceStrength)
for (const [key, value] of Object.entries(defaults)) {
if (target[key] !== undefined && target[key] !== null) continue
target[key] = Array.isArray(value)
@@ -357,6 +421,21 @@ export function ensureEmitterConfig(config: EmitterConfig): EmitterConfig {
})
}
config.imageWeightVersion = 1
if (needsGravityMigration) {
const migratedStrength = Number.isFinite(legacyGravity) && legacyGravity !== 0 ? legacyGravity : 980
config.gravity = migratedStrength
config.gravityMin = migratedStrength
config.gravityMax = migratedStrength
}
if (needsWindMigration) {
const hasLegacyWind = Number.isFinite(legacyWindX) && Number.isFinite(legacyWindY) && (legacyWindX !== 0 || legacyWindY !== 0)
config.windMin = hasLegacyWind ? Math.hypot(legacyWindX, legacyWindY) : 100
config.windMax = config.windMin
config.windDirection = hasLegacyWind ? Math.atan2(-legacyWindY, legacyWindX) * 180 / Math.PI : 0
}
if (needsForceFieldMigration) {
config.forceStrength = Number.isFinite(legacyForceStrength) && legacyForceStrength !== 0 ? legacyForceStrength : 10
}
return config
}
@@ -374,6 +453,14 @@ interface Particle {
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
resourceId: number
}
@@ -388,6 +475,9 @@ export class ParticleEmitter extends Container {
private burstTimer = 0
/** 发射器累计运行时间(秒) — 非响应式,避免污染 config */
private _elapsed = 0
/** 用于“统一时间”延迟:记录吸附从关闭切换为开启时的发射器时间。 */
private _attractionEnabledAt = 0
private _attractionWasEnabled = false
private _emitterPos: (() => IPointData) | null = null // root 世界坐标(0,0)
/** 活动粒子状态快照(供骨架 / 导出) */
states: ParticleState[] = []
@@ -406,25 +496,32 @@ export class ParticleEmitter extends Container {
const s = this.cfg.seed
if (s === this._lastSeed) return
this._lastSeed = s
if (s > 0) {
this.rng = mulberry32(s >>> 0)
} else {
this.rng = Math.random
}
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_' + this.pool.length,
sprite, active: false, boneName: 'p_' + poolIndex,
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, resourceId: 1,
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,
resourceId: 1,
})
}
}
@@ -439,11 +536,21 @@ 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)
!Array.isArray(cfg.scaleOverLifeCurve) || cfg.attraction == 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 = cfg.delay <= 0 || this._elapsed >= cfg.delay
const withinDuration = cfg.duration <= 0 || this._elapsed < cfg.delay + cfg.duration
// 发射(仅在窗口内且未超时长)
@@ -461,24 +568,53 @@ export class ParticleEmitter extends Container {
}
}
const base = this._emitterPos ? this._emitterPos() : { x: this.cfg.offsetX, y: this.cfg.offsetY }
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 }
if (cfg.useGravity) p.vy += cfg.gravity * dt
const lifeT = tFromLife(p)
if (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 (cfg.wind) { p.vx += cfg.windX * dt; p.vy += cfg.windY * dt }
// 力场(吸力/斥力,作用在发射点附近)
if (cfg.forceField && cfg.forceStrength !== 0) {
const dx = base.x - p.x, dy = base.y - p.y
const dist = Math.hypot(dx, dy) || 0.0001
if (dist < cfg.forceRadius) {
const f = cfg.forceStrength * (1 - dist / cfg.forceRadius)
p.vx += (dx / dist) * f * dt * 10
p.vy += (dy / dist) * f * dt * 10
if (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 (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 = -cfg.rootRotation * Math.PI / 180
const cosRoot = Math.cos(rootAngle), sinRoot = Math.sin(rootAngle)
const particleScreenX = cfg.centerX + cosRoot * rootScaleX * p.x - sinRoot * rootScaleY * p.y
const particleScreenY = -cfg.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) {
let falloff = 1
if (cfg.forceFalloff === 'linear') falloff = 1 - distance / cfg.forceRadius
else if (cfg.forceFalloff === 'inverse') falloff = Math.min(10, cfg.forceRadius / Math.max(distance, cfg.forceRadius * 0.1))
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
}
}
// 阻尼:速度按衰减系数逐渐减小
@@ -488,7 +624,60 @@ export class ParticleEmitter extends Container {
}
p.x += p.vx * dt
p.y += p.vy * dt
const t = 1 - p.life / p.maxLife
// 吸附使用场景全局数学坐标。延迟结束时锁定当前位置,随后沿直线或确定性弧线到达目标。
if (cfg.attraction) {
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)
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)
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)
@@ -503,7 +692,16 @@ export class ParticleEmitter extends Container {
rotationSpeed = lerp(cfg.rotationSpeedMin, cfg.rotationSpeedMax, curveAt(cfg.rotationOverLifeCurve, t))
}
p.rotation += rotationSpeed * dt
s.x = p.x; s.y = p.y; s.rotation = -p.rotation * Math.PI / 180
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)
@@ -527,7 +725,7 @@ 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: p.x, y: p.y, rotation: p.rotation, scaleX: s.scale.x, scaleY: s.scale.y, alpha: s.alpha, colorHex: rgb, resourceId: p.resourceId, active: true })
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 })
}
this.states = states
return states
@@ -572,6 +770,18 @@ export class ParticleEmitter extends Container {
}
const p = this.pool[idx]
const resource = this.pickImageResource()
// 噪声相位由全局随机种子和粒子池编号确定,同一配置重复录制得到相同轨迹。
p.noisePhaseX = seededUnit(cfg.seed, idx, 0x68bc21eb) * Math.PI * 2
p.noisePhaseY = seededUnit(cfg.seed, idx, 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.attractionCurveSide = seededUnit(cfg.seed, idx, 0xa341316c) < 0.5 ? -1 : 1
p.active = true; p.life = life; p.maxLife = life
p.x = ox; p.y = oy
p.vx = Math.cos(dirAng) * speed
@@ -686,13 +896,49 @@ export class ParticleEmitter extends Container {
this.accumulator = 0
this.burstTimer = 0
this._elapsed = 0
// 从头重新模拟:若种子>0,重设随机源到序列头,保证同一种子每次从头录制结果一致
if (this.cfg.seed > 0) { this._lastSeed = this.cfg.seed; this.rng = mulberry32(this.cfg.seed >>> 0) }
this._attractionEnabledAt = 0
this._attractionWasEnabled = false
// 从头重新模拟:任何整数种子(包括 0)都会回到同一随机序列。
this._lastSeed = this.cfg.seed
this.rng = mulberry32(this.cfg.seed >>> 0)
}
}
function lerp(a: number, b: number, t: number) { return a + (b - a) * t }
function finite(value: number, fallback: number) { return Number.isFinite(value) ? value : fallback }
function localToWorldMath(cfg: EmitterConfig, localX: number, localY: number) {
const scaleX = Math.max(0.0001, Math.abs(cfg.rootScaleX))
const scaleY = Math.max(0.0001, Math.abs(cfg.rootScaleY))
const angle = -cfg.rootRotation * Math.PI / 180
const cos = Math.cos(angle), sin = Math.sin(angle)
const screenX = cfg.centerX + cos * scaleX * localX - sin * scaleY * localY
const screenY = -cfg.centerY + sin * scaleX * localX + cos * scaleY * localY
return { x: screenX, y: -screenY }
}
function worldMathToLocal(cfg: EmitterConfig, worldX: number, worldY: number) {
const scaleX = Math.max(0.0001, Math.abs(cfg.rootScaleX))
const scaleY = Math.max(0.0001, Math.abs(cfg.rootScaleY))
const angle = -cfg.rootRotation * Math.PI / 180
const cos = Math.cos(angle), sin = Math.sin(angle)
const screenX = worldX - cfg.centerX
const screenY = -worldY + cfg.centerY
return {
x: (cos * screenX + sin * screenY) / scaleX,
y: (-sin * screenX + cos * screenY) / scaleY,
}
}
function tFromLife(particle: Pick<Particle, 'life' | 'maxLife'>) {
return Math.min(1, Math.max(0, 1 - particle.life / Math.max(0.0001, particle.maxLife)))
}
function seededUnit(seed: number, index: number, salt: number) {
let value = ((seed >>> 0) ^ Math.imul(index + 1, 0x9e3779b1) ^ salt) >>> 0
value ^= value >>> 16
value = Math.imul(value, 0x7feb352d)
value ^= value >>> 15
value = Math.imul(value, 0x846ca68b)
value ^= value >>> 16
return (value >>> 0) / 4294967296
}
function initialValue(mode: AttributeMode, min: number, max: number, curve: CurvePoint[], random: number) {
if (mode === 'fixed') return min
if (mode === 'curve') return lerp(min, max, curveAt(curve, random))