完成阶段3-4
This commit is contained in:
+275
-29
@@ -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))
|
||||
|
||||
+131
-21
@@ -40,6 +40,12 @@
|
||||
<NumSlider label="旋转" :min="-360" :max="360" :step="1" v-model="sys.config.rootRotation" />
|
||||
<NumSlider label="缩放X" :min="0.05" :max="10" :step="0.05" v-model="sys.config.rootScaleX" />
|
||||
<NumSlider label="缩放Y" :min="0.05" :max="10" :step="0.05" v-model="sys.config.rootScaleY" />
|
||||
<div class="seed-row scene-seed">
|
||||
<label for="system-seed">随机种子</label>
|
||||
<input id="system-seed" class="seed-input" type="number" min="0" step="1" v-model.number="sys.config.seed" title="控制粒子系统内全部随机属性、资源选择与修改器随机采样" />
|
||||
<button class="seed-random" title="生成新的全局随机种子" @click="randomizeSeed">⚄</button>
|
||||
</div>
|
||||
<div class="seed-hint">控制初始随机参数、资源选择和修改器随机采样;相同种子可复现相同效果</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -238,12 +244,6 @@
|
||||
curve-label="生命周期 →"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="seed-row">
|
||||
<label for="particle-seed">随机种子</label>
|
||||
<input id="particle-seed" class="seed-input" type="number" min="0" step="1" v-model.number="sys.config.seed" />
|
||||
<button class="seed-random" title="生成随机种子" @click="randomizeSeed">⚄</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -370,20 +370,108 @@
|
||||
<span class="caret">{{ collapsed.mods ? '▸' : '▾' }}</span><span>修改器 (Modifiers)</span>
|
||||
</div>
|
||||
<div class="group-body" v-show="!collapsed.mods">
|
||||
<label class="row"><span>移动噪声</span><input type="checkbox" v-model="sys.config.noise" /></label>
|
||||
<NumSlider v-if="sys.config.noise" label="噪声强度" :min="0" :max="200" :step="5" v-model="sys.config.noiseAmt" />
|
||||
<label class="row"><span>重力</span><input type="checkbox" v-model="sys.config.useGravity" /></label>
|
||||
<NumSlider v-if="sys.config.useGravity" label="重力大小" :min="-500" :max="500" :step="10" v-model="sys.config.gravity" />
|
||||
<label class="row"><span>风力</span><input type="checkbox" v-model="sys.config.wind" /></label>
|
||||
<template v-if="sys.config.wind">
|
||||
<NumSlider label="风力X" :min="-300" :max="300" :step="1" v-model="sys.config.windX" />
|
||||
<NumSlider label="风力Y" :min="-300" :max="300" :step="1" v-model="sys.config.windY" />
|
||||
</template>
|
||||
<label class="row"><span>力场</span><input type="checkbox" v-model="sys.config.forceField" /></label>
|
||||
<template v-if="sys.config.forceField">
|
||||
<NumSlider label="力场强度" :min="-300" :max="300" :step="5" v-model="sys.config.forceStrength" />
|
||||
<NumSlider label="力场半径" :min="1" :max="500" :step="1" v-model="sys.config.forceRadius" />
|
||||
</template>
|
||||
<div class="modifier-item">
|
||||
<label class="switch-row modifier-switch">
|
||||
<input type="checkbox" v-model="sys.config.noise" />
|
||||
<span class="switch-ui"></span><span>开启移动噪声 (Movement Noise)</span>
|
||||
</label>
|
||||
<div v-if="sys.config.noise" class="modifier-params">
|
||||
<NumSlider label="强度" :min="0" :max="100" :step="1" v-model="sys.config.noiseAmt" />
|
||||
<NumSlider label="波动范围" :min="0" :max="500" :step="1" v-model="sys.config.noiseRange" />
|
||||
<NumSlider label="时间流速" :min="0" :max="10" :step="0.1" v-model="sys.config.noiseSpeed" />
|
||||
<div class="modifier-hint">强度控制影响比例 · 波动范围控制最大偏移 · 时间流速控制变化速度</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modifier-item">
|
||||
<label class="switch-row modifier-switch">
|
||||
<input type="checkbox" v-model="sys.config.useGravity" />
|
||||
<span class="switch-ui"></span><span>开启重力 (Gravity)</span>
|
||||
</label>
|
||||
<div v-if="sys.config.useGravity" class="modifier-params gravity-params">
|
||||
<div class="modifier-mode-row">
|
||||
<button class="axis-mode" :class="{ on: sys.config.gravityMode === 'fixed' }" @click="sys.config.gravityMode = 'fixed'">固定</button>
|
||||
<button class="axis-mode" :class="{ on: sys.config.gravityMode === 'random' }" @click="sys.config.gravityMode = 'random'">随机</button>
|
||||
</div>
|
||||
<NumSlider v-if="sys.config.gravityMode === 'fixed'" label="重力强度" :min="-3000" :max="3000" :step="10" v-model="sys.config.gravityMin" />
|
||||
<template v-else>
|
||||
<NumSlider label="强度 min" :min="-3000" :max="3000" :step="10" v-model="sys.config.gravityMin" />
|
||||
<NumSlider label="强度 max" :min="-3000" :max="3000" :step="10" v-model="sys.config.gravityMax" />
|
||||
</template>
|
||||
<NumSlider label="方向" :min="-360" :max="360" :step="1" v-model="sys.config.gravityDirection" />
|
||||
<div class="gravity-curve-title"><span>生命周期内重力</span><span>纵轴:强度 0~1</span></div>
|
||||
<CurveEditor v-model="sys.config.gravityCurve" :default-value="FLAT_CURVE" />
|
||||
<div class="gravity-curve-axis"><span>0</span><span>生命周期 →</span><span>1</span></div>
|
||||
<div class="modifier-hint">0° 向右 · -90° 向下 · 负强度会反转方向</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modifier-item">
|
||||
<label class="switch-row modifier-switch">
|
||||
<input type="checkbox" v-model="sys.config.wind" />
|
||||
<span class="switch-ui"></span><span>开启风力 (Wind)</span>
|
||||
</label>
|
||||
<div v-if="sys.config.wind" class="modifier-params gravity-params">
|
||||
<div class="modifier-mode-row">
|
||||
<button class="axis-mode" :class="{ on: sys.config.windMode === 'fixed' }" @click="sys.config.windMode = 'fixed'">固定</button>
|
||||
<button class="axis-mode" :class="{ on: sys.config.windMode === 'random' }" @click="sys.config.windMode = 'random'">随机</button>
|
||||
</div>
|
||||
<NumSlider v-if="sys.config.windMode === 'fixed'" label="风力强度" :min="-3000" :max="3000" :step="10" v-model="sys.config.windMin" />
|
||||
<template v-else>
|
||||
<NumSlider label="强度 min" :min="-3000" :max="3000" :step="10" v-model="sys.config.windMin" />
|
||||
<NumSlider label="强度 max" :min="-3000" :max="3000" :step="10" v-model="sys.config.windMax" />
|
||||
</template>
|
||||
<NumSlider label="方向" :min="-360" :max="360" :step="1" v-model="sys.config.windDirection" />
|
||||
<div class="gravity-curve-title"><span>生命周期内风力</span><span>纵轴:强度 0~1</span></div>
|
||||
<CurveEditor v-model="sys.config.windCurve" :default-value="FLAT_CURVE" />
|
||||
<div class="gravity-curve-axis"><span>0</span><span>生命周期 →</span><span>1</span></div>
|
||||
<div class="modifier-hint">0° 向右 · -90° 向下 · 负强度会反转方向</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modifier-item">
|
||||
<label class="switch-row modifier-switch">
|
||||
<input type="checkbox" v-model="sys.config.forceField" />
|
||||
<span class="switch-ui"></span><span>开启力场 (Force Field)</span>
|
||||
</label>
|
||||
<div v-if="sys.config.forceField" class="modifier-params">
|
||||
<NumSlider label="中心 X" :min="-5000" :max="5000" :step="1" v-model="sys.config.forceCenterX" />
|
||||
<NumSlider label="中心 Y" :min="-5000" :max="5000" :step="1" v-model="sys.config.forceCenterY" />
|
||||
<NumSlider label="力场强度" :min="-300" :max="300" :step="1" v-model="sys.config.forceStrength" />
|
||||
<NumSlider label="力场半径" :min="1" :max="2000" :step="1" v-model="sys.config.forceRadius" />
|
||||
<label class="row"><span>衰减模式</span>
|
||||
<select v-model="sys.config.forceFalloff" class="inp">
|
||||
<option value="constant">恒定(范围内力度不变)</option>
|
||||
<option value="linear">线性衰减(边缘为 0)</option>
|
||||
<option value="inverse">距离反比(中心最强)</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="modifier-hint">正数吸向中心 · 负数向外排斥 · 可在画布内拖动力场圆</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modifier-item">
|
||||
<label class="switch-row modifier-switch">
|
||||
<input type="checkbox" v-model="sys.config.attraction" />
|
||||
<span class="switch-ui"></span><span>开启吸附 (Attraction)</span>
|
||||
</label>
|
||||
<div v-if="sys.config.attraction" class="modifier-params">
|
||||
<NumSlider label="吸附点 X" :min="-5000" :max="5000" :step="1" v-model="sys.config.attractionCenterX" />
|
||||
<NumSlider label="吸附点 Y" :min="-5000" :max="5000" :step="1" v-model="sys.config.attractionCenterY" />
|
||||
<NumSlider label="吸附半径" :min="0" :max="2000" :step="1" v-model="sys.config.attractionRadius" />
|
||||
<NumSlider label="吸附时长" :min="0.05" :max="30" :step="0.05" v-model="sys.config.attractionDuration" />
|
||||
<NumSlider label="延迟时间" :min="0" :max="30" :step="0.05" v-model="sys.config.attractionDelay" />
|
||||
<label class="row"><span>延迟方式</span>
|
||||
<select v-model="sys.config.attractionDelayMode" class="inp">
|
||||
<option value="global">统一时间</option>
|
||||
<option value="particle">单粒子生命</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="row"><span>吸附方式</span>
|
||||
<select v-model="sys.config.attractionPath" class="inp">
|
||||
<option value="linear">直线</option>
|
||||
<option value="curve">曲线</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="modifier-hint">延迟后从当前位置开始吸附 · 到达红色范围即死亡 · 可在画布内拖动吸附圆</div>
|
||||
</div>
|
||||
</div>
|
||||
<label class="row"><span>拖尾</span><input type="checkbox" v-model="sys.config.trail" /></label>
|
||||
<label class="row"><span>碰撞</span><input type="checkbox" v-model="sys.config.collision" /><span class="tag">预留</span></label>
|
||||
<label class="row"><span>路径跟随</span><input type="checkbox" v-model="sys.config.pathFollow" /><span class="tag">预留</span></label>
|
||||
@@ -416,6 +504,9 @@ import ParticleAttributeControl from './ParticleAttributeControl.vue'
|
||||
import ColorGradientEditor from './ColorGradientEditor.vue'
|
||||
|
||||
const store = useParticleStore()
|
||||
// 默认场景始终有一个可编辑的粒子系统;热更新保留现有系统时不重复创建。
|
||||
if (!store.systems.length) store.addSystem()
|
||||
|
||||
const activeIdx = computed(() => {
|
||||
const i = store.systems.findIndex((s) => s.id === store.activeId)
|
||||
return i >= 0 ? i : 0
|
||||
@@ -439,7 +530,13 @@ const ALPHA_CURVE = [{ x: 0, y: 0 }, { x: 0.2, y: 1 }, { x: 0.8, y: 1 }, { x: 1,
|
||||
const RESOURCE_ALPHA_CURVE = [{ x: 0, y: 0 }, { x: 0.1, y: 1 }, { x: 0.9, y: 1 }, { x: 1, y: 0 }]
|
||||
|
||||
const collapsed = reactive<Record<string, boolean>>({
|
||||
scene: false, emitMode: false, shape: false, attr: false, look: false, mods: false, export: false,
|
||||
scene: false,
|
||||
emitMode: true,
|
||||
shape: true,
|
||||
attr: true,
|
||||
look: true,
|
||||
mods: true,
|
||||
export: true,
|
||||
})
|
||||
function toggle(key: string) { collapsed[key] = !collapsed[key] }
|
||||
|
||||
@@ -788,4 +885,17 @@ function toggleResourceLock(resourceId: number) {
|
||||
.seed-input:focus { border-color: #625ff1; }
|
||||
.seed-random { width: 34px; height: 30px; border: 1px solid #3a4860; border-radius: 6px; background: #2c3950; color: #d7def0; cursor: pointer; }
|
||||
.seed-random:hover { background: #394964; }
|
||||
.scene-seed { margin-top: 10px; }
|
||||
.seed-hint { margin: 5px 0 1px 72px; color: #657188; font-size: 9px; line-height: 1.45; }
|
||||
|
||||
/* 修改器 */
|
||||
.modifier-item { padding: 8px 0 10px; border-bottom: 1px solid #2a3549; }
|
||||
.modifier-switch { font-weight: 600; }
|
||||
.modifier-params { margin: 8px 0 0 10px; padding: 2px 0 2px 10px; border-left: 2px solid #344158; }
|
||||
.modifier-hint { margin: 7px 0 0 2px; color: #647086; font-size: 9px; line-height: 1.5; }
|
||||
.modifier-mode-row { display: flex; justify-content: flex-end; gap: 4px; margin-bottom: 6px; }
|
||||
.gravity-curve-title { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin: 10px 0 6px; color: #9ba7bd; font-size: 11px; }
|
||||
.gravity-curve-title span:last-child { color: #657188; font-size: 9px; }
|
||||
.gravity-curve-axis { display: flex; justify-content: space-between; margin: 3px 34px 0 1px; color: #5f6b82; font-size: 9px; }
|
||||
.gravity-params :deep(.ce-svg) { height: 96px; background: #0f1625; border-color: #30405a; }
|
||||
</style>
|
||||
|
||||
@@ -83,6 +83,8 @@ let world: Container | null = null
|
||||
let originGfx: Graphics | null = null
|
||||
let boneGfx: Graphics | null = null
|
||||
let shapeGfx: Graphics | null = null
|
||||
let forceFieldGfx: Graphics | null = null
|
||||
let attractionGfx: Graphics | null = null
|
||||
let gizmoGfx: Graphics | null = null
|
||||
let axisGfx: Graphics | null = null
|
||||
let axisLabelLayer: Container | null = null // 文字图层,不随 world 缩放,避免放大变糊
|
||||
@@ -110,6 +112,10 @@ function createLayers() {
|
||||
world.addChild(originGfx)
|
||||
shapeGfx = new Graphics() // 发射器形状范围(绿圆/蓝矩形/锥形)
|
||||
world.addChild(shapeGfx)
|
||||
forceFieldGfx = new Graphics() // 选中系统的全局力场范围
|
||||
world.addChild(forceFieldGfx)
|
||||
attractionGfx = new Graphics() // 选中系统的全局吸附范围
|
||||
world.addChild(attractionGfx)
|
||||
gizmoGfx = new Graphics() // root 变换 gizmo 手柄
|
||||
world.addChild(gizmoGfx)
|
||||
boneGfx = new Graphics()
|
||||
@@ -234,6 +240,10 @@ function onWheel(e: WheelEvent) {
|
||||
// 鼠标左键长按拖动画布(平移)
|
||||
function onPanDown(e: PointerEvent) {
|
||||
if (e.button !== 0) return
|
||||
// 吸附点优先于力场;重叠时拖动红色吸附圆。
|
||||
if (onAttractionDown(e)) return
|
||||
// 力场使用全局坐标,按住圆内任意位置时优先拖动力场中心。
|
||||
if (onForceFieldDown(e)) return
|
||||
// gizmo 工具激活且选中系统 → 拖拽根节点变换;否则画布平移
|
||||
if (activeTool.value && store.systems.length) {
|
||||
onGizmoDown(e)
|
||||
@@ -253,6 +263,76 @@ function onPanDown(e: PointerEvent) {
|
||||
window.addEventListener('pointercancel', up)
|
||||
}
|
||||
|
||||
function pointerToWorldMath(clientX: number, clientY: number) {
|
||||
const rect = holder.value!.getBoundingClientRect()
|
||||
const scale = Math.max(0.0001, store.editor.viewScale)
|
||||
const screenX = clientX - rect.left
|
||||
const screenY = clientY - rect.top
|
||||
return {
|
||||
x: (screenX - app!.screen.width / 2 - store.editor.viewX) / scale,
|
||||
y: -(screenY - app!.screen.height * 0.5 - store.editor.viewY) / scale,
|
||||
}
|
||||
}
|
||||
|
||||
/** 命中当前选中系统的力场圆时开始拖拽;返回 true 表示事件已被力场接管。 */
|
||||
function onForceFieldDown(e: PointerEvent) {
|
||||
const sys = store.systems.find((item) => item.id === store.activeId)
|
||||
const cfg = sys?.config
|
||||
if (!cfg?.forceField || cfg.forceRadius <= 0) return false
|
||||
const startPointer = pointerToWorldMath(e.clientX, e.clientY)
|
||||
const distance = Math.hypot(startPointer.x - cfg.forceCenterX, startPointer.y - cfg.forceCenterY)
|
||||
if (distance > cfg.forceRadius) return false
|
||||
const startX = cfg.forceCenterX
|
||||
const startY = cfg.forceCenterY
|
||||
if (holder.value) holder.value.style.cursor = 'grabbing'
|
||||
const move = (event: PointerEvent) => {
|
||||
const pointer = pointerToWorldMath(event.clientX, event.clientY)
|
||||
cfg.forceCenterX = startX + pointer.x - startPointer.x
|
||||
cfg.forceCenterY = startY + pointer.y - startPointer.y
|
||||
}
|
||||
const up = () => {
|
||||
if (holder.value) holder.value.style.cursor = ''
|
||||
window.removeEventListener('pointermove', move)
|
||||
window.removeEventListener('pointerup', up)
|
||||
window.removeEventListener('pointercancel', up)
|
||||
}
|
||||
window.addEventListener('pointermove', move)
|
||||
window.addEventListener('pointerup', up)
|
||||
window.addEventListener('pointercancel', up)
|
||||
e.preventDefault()
|
||||
return true
|
||||
}
|
||||
|
||||
/** 命中吸附圆(半径为 0 时使用屏幕 12px 热区)时拖动全局吸附点。 */
|
||||
function onAttractionDown(e: PointerEvent) {
|
||||
const sys = store.systems.find((item) => item.id === store.activeId)
|
||||
const cfg = sys?.config
|
||||
if (!cfg?.attraction) return false
|
||||
const startPointer = pointerToWorldMath(e.clientX, e.clientY)
|
||||
const hitRadius = Math.max(cfg.attractionRadius, 12 / Math.max(0.0001, store.editor.viewScale))
|
||||
const distance = Math.hypot(startPointer.x - cfg.attractionCenterX, startPointer.y - cfg.attractionCenterY)
|
||||
if (distance > hitRadius) return false
|
||||
const startX = cfg.attractionCenterX
|
||||
const startY = cfg.attractionCenterY
|
||||
if (holder.value) holder.value.style.cursor = 'grabbing'
|
||||
const move = (event: PointerEvent) => {
|
||||
const pointer = pointerToWorldMath(event.clientX, event.clientY)
|
||||
cfg.attractionCenterX = startX + pointer.x - startPointer.x
|
||||
cfg.attractionCenterY = startY + pointer.y - startPointer.y
|
||||
}
|
||||
const up = () => {
|
||||
if (holder.value) holder.value.style.cursor = ''
|
||||
window.removeEventListener('pointermove', move)
|
||||
window.removeEventListener('pointerup', up)
|
||||
window.removeEventListener('pointercancel', up)
|
||||
}
|
||||
window.addEventListener('pointermove', move)
|
||||
window.addEventListener('pointerup', up)
|
||||
window.addEventListener('pointercancel', up)
|
||||
e.preventDefault()
|
||||
return true
|
||||
}
|
||||
|
||||
// root gizmo 拖拽:位移 / 旋转 / 缩放(改当前选中系统的 config,由 loop 实时应用)
|
||||
function onGizmoDown(e: PointerEvent) {
|
||||
const sys = store.systems.find((s) => s.id === store.activeId)
|
||||
@@ -413,6 +493,55 @@ function drawShapeRange() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 绘制当前选中系统的全局力场;不应用粒子系统 root 变换。 */
|
||||
function drawForceField() {
|
||||
if (!forceFieldGfx) return
|
||||
const g = forceFieldGfx
|
||||
g.clear()
|
||||
const sys = store.systems.find((item) => item.id === store.activeId)
|
||||
const cfg = sys?.config
|
||||
if (!cfg?.forceField || cfg.forceRadius <= 0) return
|
||||
const x = cfg.forceCenterX
|
||||
const y = -cfg.forceCenterY
|
||||
const color = cfg.forceStrength >= 0 ? 0x58c7ff : 0xff6b7a
|
||||
g.lineStyle(2, color, 0.85)
|
||||
g.beginFill(color, 0.08)
|
||||
g.drawCircle(x, y, cfg.forceRadius)
|
||||
g.endFill()
|
||||
g.lineStyle(1, color, 0.7)
|
||||
g.moveTo(x - 9, y); g.lineTo(x + 9, y)
|
||||
g.moveTo(x, y - 9); g.lineTo(x, y + 9)
|
||||
g.beginFill(color, 0.95)
|
||||
g.drawCircle(x, y, 4)
|
||||
g.endFill()
|
||||
}
|
||||
|
||||
/** 绘制全局吸附点与吸附完成半径;半径为 0 时显示为红色点。 */
|
||||
function drawAttraction() {
|
||||
if (!attractionGfx) return
|
||||
const g = attractionGfx
|
||||
g.clear()
|
||||
const sys = store.systems.find((item) => item.id === store.activeId)
|
||||
const cfg = sys?.config
|
||||
if (!cfg?.attraction) return
|
||||
const x = cfg.attractionCenterX
|
||||
const y = -cfg.attractionCenterY
|
||||
const radius = Math.max(0, cfg.attractionRadius)
|
||||
const color = 0xff4d5f
|
||||
if (radius > 0) {
|
||||
g.lineStyle(2, color, 0.95)
|
||||
g.beginFill(color, 0.09)
|
||||
g.drawCircle(x, y, radius)
|
||||
g.endFill()
|
||||
}
|
||||
g.lineStyle(1, color, 0.85)
|
||||
g.moveTo(x - 10, y); g.lineTo(x + 10, y)
|
||||
g.moveTo(x, y - 10); g.lineTo(x, y + 10)
|
||||
g.beginFill(color, 1)
|
||||
g.drawCircle(x, y, radius > 0 ? 4 : 6)
|
||||
g.endFill()
|
||||
}
|
||||
|
||||
// 根变换 gizmo 手柄:当前选中系统,按 activeTool 绘制(位移/旋转/缩放)。中心 = root 位置。
|
||||
// 手柄几何在 root 局部坐标系定义(原点 = root 中心,ly 数学上正),经 root 缩放+旋转+平移到 root 中心,
|
||||
// 与 drawShapeRange 的变换一致 → 手柄随 root 旋转/缩放/位移实时变化。
|
||||
@@ -569,6 +698,8 @@ function loop() {
|
||||
}
|
||||
drawOrigin()
|
||||
drawShapeRange()
|
||||
drawForceField()
|
||||
drawAttraction()
|
||||
drawGizmo()
|
||||
drawBones(collected)
|
||||
if (info.value) { info.value.systems = store.systems.length; info.value.particles = total }
|
||||
|
||||
+279
@@ -0,0 +1,279 @@
|
||||
# 网页粒子系统 · 阶段三
|
||||
|
||||
> 阶段定位:**粒子属性与外观资源定版**
|
||||
> 完成日期:2026-08-28
|
||||
> 工程目录:`/Users/tianmokeji/Desktop/SpineParticle`
|
||||
> 技术栈:Vue 3 + TypeScript + Pinia + PixiJS 7 + Vite
|
||||
|
||||
---
|
||||
|
||||
## 一、阶段结论
|
||||
|
||||
阶段三已完成以下两块核心能力:
|
||||
|
||||
1. **粒子属性面板**:初始生命周期、速度、缩放、旋转,以及生命周期内速度、缩放、旋转,均已形成统一、可复用的调节结构,并接入粒子计算。
|
||||
2. **外观与资源面板**:支持多图片资源混合发射、每张图片独立的占比、缩放、轴心、颜色、混合模式和透明度;生命周期颜色已升级为可编辑的多色渐变。
|
||||
|
||||
当前项目已经具备较完整的“参数编辑 → 实时预览 → 固定帧录制/回放”闭环。接下来不建议继续扩充同一面板,而应进入**修改器完善、工程保存/加载和导出准备**。
|
||||
|
||||
---
|
||||
|
||||
## 二、粒子属性模块完成情况
|
||||
|
||||
### 2.1 粒子初始参数
|
||||
|
||||
| 属性 | 已支持模式 | 补充说明 |
|
||||
|---|---|---|
|
||||
| 初始生命周期 | 固定、随机 | 曲线选项已按最终需求移除 |
|
||||
| 初始速度 | 固定、随机 | 实时影响粒子初速度 |
|
||||
| 初始缩放 | 固定、随机 | 支持统一缩放与 XY 分离 |
|
||||
| 初始旋转 | 固定、随机、发射方向 | 发射方向模式可设置角度偏移 |
|
||||
|
||||
初始参数使用统一的 `ParticleAttributeControl.vue`,避免每种属性重复实现模式按钮、数值范围和曲线区域。
|
||||
|
||||
### 2.2 生命周期内属性
|
||||
|
||||
- 生命周期内速度:独立开关,支持固定、随机、曲线倍率。
|
||||
- 生命周期内缩放:独立开关,支持固定、随机、曲线,并支持统一与 XY 分离。
|
||||
- 生命周期内旋转:独立开关,支持固定、随机、曲线旋转速度。
|
||||
- 曲线调节结果已经接入 `ParticleEmitter.update()`,不再是只改变界面的空配置。
|
||||
- 每个曲线编辑器右侧均提供重置按钮,可恢复该属性的默认曲线。
|
||||
- 随机种子支持手动输入和快速生成;它是场景对象下的全局属性,控制初始属性、资源选择和修改器随机采样,包括 0 在内的任意整数种子均可复现相同发射序列。
|
||||
|
||||
### 2.3 曲线编辑器
|
||||
|
||||
当前曲线编辑器支持:
|
||||
|
||||
- 拖动关键点;
|
||||
- 双击空白处新增关键点;
|
||||
- 双击关键点删除;
|
||||
- 最少保留两个关键点;
|
||||
- 按属性传入不同默认曲线并单独重置;
|
||||
- 生命周期横轴与归一化数值纵轴求值。
|
||||
|
||||
---
|
||||
|
||||
## 三、外观与资源模块完成情况
|
||||
|
||||
### 3.1 系统透明度
|
||||
|
||||
- 支持固定透明度和生命周期曲线透明度。
|
||||
- 固定模式直接使用固定值,不再受到透明度曲线影响。
|
||||
- 曲线模式复用 `CurveEditor.vue`,支持关键点编辑和重置。
|
||||
|
||||
### 3.2 多图片资源
|
||||
|
||||
每个粒子系统默认使用 `star.png`,并支持:
|
||||
|
||||
- 点击缩略图选择本地 PNG、JPEG、WebP 或 GIF 图片;
|
||||
- 通过右上角 `+` 添加多张图片资源;
|
||||
- 每次发射按资源占比随机选择图片;
|
||||
- 单资源时占比强制为 100%;
|
||||
- 多资源时总占比始终保持 100%;
|
||||
- 可锁定某张图片的当前占比,调整其他资源时只重新分配未锁定资源;
|
||||
- 每张图片独立设置缩放和轴心 X/Y;
|
||||
- 删除资源后自动重新分配占比;
|
||||
- 重置资源卡后恢复默认图片、名称、缩放、轴心、颜色、混合模式、透明度和占比状态。
|
||||
|
||||
粒子状态中记录 `resourceId`,录制和回放时能够恢复对应的贴图与轴心,不会全部退回默认图片。
|
||||
|
||||
### 3.3 每张图片独立颜色
|
||||
|
||||
每张资源卡独立支持:
|
||||
|
||||
- 固定色;
|
||||
- 生命周期颜色;
|
||||
- 正常、相加、相乘、滤色四种混合模式。
|
||||
|
||||
固定色可通过系统颜色选择器或十六进制输入框精确设置。输入支持:
|
||||
|
||||
- `#71ffae`;
|
||||
- `71ffae`;
|
||||
- `#fff` 等三位简写。
|
||||
|
||||
无效颜色值不会覆盖当前颜色。
|
||||
|
||||
### 3.4 生命周期多色渐变
|
||||
|
||||
新增 `ColorGradientEditor.vue`,生命周期颜色不再局限于起始色和结束色,而是支持任意数量色标:
|
||||
|
||||
- 点击 `+` 在当前最大色标间隔中添加颜色;
|
||||
- 双击色带,在指定生命周期位置添加颜色;
|
||||
- 拖动色标调整位置;
|
||||
- 输入 0~100 的百分比精确设置位置;
|
||||
- 使用颜色选择器或十六进制输入框精确调色;
|
||||
- 双击色标或点击删除按钮移除色标;
|
||||
- 最少保留两个色标;
|
||||
- 粒子颜色按生命周期依次经过全部色标。
|
||||
|
||||
预览阶段使用**线性 RGB 插值**计算相邻色标间的颜色。
|
||||
|
||||
### 3.5 每张图片独立透明度
|
||||
|
||||
每张资源卡支持三种模式:
|
||||
|
||||
| 模式 | 行为 |
|
||||
|---|---|
|
||||
| 关闭 | 跟随粒子系统透明度设置 |
|
||||
| 固定 | 使用该图片自己的固定透明度,不受系统透明度曲线影响 |
|
||||
| 曲线 | 使用该图片自己的生命周期透明度曲线 |
|
||||
|
||||
独立曲线同样支持关键点编辑、曲线模式选择和恢复默认曲线。
|
||||
|
||||
---
|
||||
|
||||
## 四、界面与交互修正
|
||||
|
||||
- 左侧属性面板、右上画布和右下时间轴固定占满浏览器视口。
|
||||
- 页面根节点禁止产生额外纵向滚动,只有各自面板内部滚动。
|
||||
- 修复展开属性内容后页面下方出现大面积空白的问题。
|
||||
- 曲线重置按钮统一放在曲线面板右侧,不覆盖绘图区。
|
||||
- 所有数字输入框隐藏浏览器自带的上下微调箭头。
|
||||
- 数值仍可通过滑块、直接输入和键盘方向键调节。
|
||||
- 图片占比锁定、删除、重置等状态均提供禁用和高亮反馈。
|
||||
|
||||
---
|
||||
|
||||
## 五、核心数据与运行逻辑
|
||||
|
||||
### 5.1 `ParticleImageResource`
|
||||
|
||||
图片资源已经从简单路径扩展为完整的独立配置单元,主要包含:
|
||||
|
||||
- 资源标识、纹理、名称、目录、预览地址;
|
||||
- 占比与锁定状态;
|
||||
- 缩放与轴心;
|
||||
- 固定色或生命周期渐变;
|
||||
- 混合模式;
|
||||
- 独立透明度模式、固定值和曲线。
|
||||
|
||||
### 5.2 发射与更新
|
||||
|
||||
1. `spawnOne()` 根据占比选择图片资源。
|
||||
2. 创建粒子时应用该资源的纹理、轴心、缩放、初始颜色和混合模式。
|
||||
3. `update()` 根据粒子的 `resourceId` 查找资源配置。
|
||||
4. 每帧更新该资源的生命周期颜色、独立透明度和混合模式。
|
||||
5. `capture()` / `apply()` 记录并恢复资源标识,保证时间轴回放一致。
|
||||
|
||||
### 5.3 旧配置兼容
|
||||
|
||||
`ensureEmitterConfig()` 会为热更新对象或旧工程配置补齐新增字段,包括图片权重、锁定、颜色渐变和独立透明度,避免 `undefined` 进入粒子计算。
|
||||
|
||||
---
|
||||
|
||||
## 六、本阶段新增或重点调整的文件
|
||||
|
||||
| 文件 | 作用 |
|
||||
|---|---|
|
||||
| `src/views/ParticleAttributeControl.vue` | 复用固定、随机、曲线和发射方向属性结构 |
|
||||
| `src/views/ColorGradientEditor.vue` | 多色生命周期渐变编辑器 |
|
||||
| `src/views/CurveEditor.vue` | 曲线关键点编辑与默认曲线重置 |
|
||||
| `src/views/NumSlider.vue` | 滑块与数字输入,支持禁用状态 |
|
||||
| `src/views/ParticlePanel.vue` | 粒子属性及外观资源面板 |
|
||||
| `src/core/particleEmitter.ts` | 属性、图片权重、颜色、透明度的实际运行逻辑 |
|
||||
| `src/views/Stage.vue` | 默认纹理注入、多资源录制/回放配置监听 |
|
||||
| `src/style.css` | 视口滚动约束、数字微调箭头统一隐藏 |
|
||||
|
||||
---
|
||||
|
||||
## 七、验证结果
|
||||
|
||||
本阶段进行了浏览器交互测试和生产构建验证,已确认:
|
||||
|
||||
- 三个生命周期属性同时展开不会产生页面级额外滚动;
|
||||
- 固定透明度不受曲线影响;
|
||||
- 多图片能够同时发射并正确恢复贴图;
|
||||
- 单图片为 100%,多图片总占比为 100%;
|
||||
- 三资源场景下锁定 33%,将第二项调为 50%,第三项自动变为 17%;
|
||||
- 资源卡重置可恢复缩放 1、轴心 0.5/0.5 和全部外观默认值;
|
||||
- 不同资源可分别使用不同颜色、混合模式和透明度;
|
||||
- 多色渐变可新增第三个色标并精确修改颜色与位置;
|
||||
- 固定色和色标颜色的十六进制输入可同步到颜色控件;
|
||||
- `npm run build` 通过;
|
||||
- `git diff --check` 通过。
|
||||
|
||||
Vite 仍会提示主包大于 500 kB,这是体积警告,不影响当前运行。
|
||||
|
||||
---
|
||||
|
||||
## 八、当前边界与已知风险
|
||||
|
||||
1. **本地图片只存在于当前运行内存**:目前使用 Data URL 和 Pixi Texture,尚未实现工程文件持久化、重新加载或资源打包。
|
||||
2. **仅支持单张静态纹理资源**:序列帧、雪碧图和视频纹理尚未实现。
|
||||
3. **曲线模式目前主要是配置语义**:界面提供“跟随导出 / 线性 / 贝塞尔”,当前预览求值仍以关键点间线性插值为主,贝塞尔求值和导出规则需要后续统一实现。
|
||||
4. **渐变使用线性 RGB 插值**:暂未提供颜色空间选择、独立 Alpha 色标或 HDR 颜色。
|
||||
5. **修改器完成度不一致**:重力、风力、力场和阻尼已有运行逻辑;移动噪声、拖尾、碰撞、路径跟随仍需实现或补齐。
|
||||
6. **粒子池上限固定为 400**:复杂多系统场景需要性能压测和可配置上限。
|
||||
7. **工程配置没有正式版本协议**:当前仅通过字段补齐兼容旧对象,进入保存/加载阶段前应定义 schema version 和迁移规则。
|
||||
8. **缺少自动化测试**:当前以 TypeScript 构建和浏览器交互验证为主。
|
||||
|
||||
---
|
||||
|
||||
## 九、下一步开发建议
|
||||
|
||||
### 优先级 P0:工程保存与加载
|
||||
|
||||
建议优先建立正式的工程配置格式,这是后续预设、分享和导出的基础。
|
||||
|
||||
- 定义 `ProjectSchema` 和 `schemaVersion`;
|
||||
- 将 Pixi Texture 等运行时对象与可序列化配置分离;
|
||||
- 保存图片资源信息和颜色/曲线/渐变数据;
|
||||
- 支持导出、导入工程 JSON;
|
||||
- 明确本地图片的持久化方案:Data URL 内嵌、ZIP 工程包或 File System Access API;
|
||||
- 为旧版本配置提供迁移函数。
|
||||
|
||||
### 优先级 P1:修改器模块定版
|
||||
|
||||
按现有面板顺序继续开发,避免跨模块扩散:
|
||||
|
||||
1. 移动噪声:确定噪声类型、频率、强度和种子关系;
|
||||
2. 重力、风力、力场:复核数学坐标系下的方向与单位;
|
||||
3. 拖尾:先定义数据结构与性能上限,再做渲染;
|
||||
4. 碰撞:从简单矩形/圆形碰撞体开始;
|
||||
5. 路径跟随:与场景对象中的轨道路径统一设计。
|
||||
|
||||
### 优先级 P2:撤销/重做与预设
|
||||
|
||||
- 建立配置级历史记录,不记录每帧粒子状态;
|
||||
- 支持 Ctrl/Cmd + Z、Ctrl/Cmd + Shift + Z;
|
||||
- 为火花、烟雾、雪花、光点等建立预设;
|
||||
- 预设只保存可序列化配置和资源引用。
|
||||
|
||||
### 优先级 P3:曲线与渐变能力统一
|
||||
|
||||
- 抽离统一的曲线求值模块;
|
||||
- 真正实现线性、贝塞尔、阶跃插值;
|
||||
- 明确“跟随导出”在预览阶段的行为;
|
||||
- 为颜色渐变增加可选 Alpha 色标;
|
||||
- 让曲线和渐变共享键盘操作、选中和删除规则。
|
||||
|
||||
### 优先级 P4:导出准备
|
||||
|
||||
- 固化 `ParticleState` 到逻辑骨骼关键帧的映射;
|
||||
- 明确图片资源、混合模式和颜色时间轴在 Spine 版本中的表达;
|
||||
- 先输出内部中间格式,再适配 Spine 3.8 / 4.0 / 4.1 / 4.2;
|
||||
- 对固定帧采样、整数帧对齐和对象池骨骼数量进行验证。
|
||||
|
||||
### 工程质量建议
|
||||
|
||||
- 将 `EmitterConfig`、资源类型、曲线工具拆分到独立文件;
|
||||
- 为占比重分配、曲线求值、颜色渐变和配置迁移增加单元测试;
|
||||
- 对 1、5、20 个系统及 400 粒子上限进行性能测试;
|
||||
- 在进入导出阶段前处理 Vite 主包体积和按需加载。
|
||||
|
||||
---
|
||||
|
||||
## 十、下一阶段推荐验收目标
|
||||
|
||||
建议把阶段四定义为:**修改器完善 + 工程配置保存/加载**。
|
||||
|
||||
阶段四完成时应满足:
|
||||
|
||||
1. 当前工程可保存为带版本号的配置文件;
|
||||
2. 刷新页面后导入配置,可恢复全部系统、图片资源、颜色、曲线和渐变;
|
||||
3. 自定义图片有明确的持久化方案,不依赖旧页面内存;
|
||||
4. 移动噪声、重力、风力、力场和阻尼行为经过坐标方向验证;
|
||||
5. 拖尾、碰撞和路径跟随明确为“已实现”或“正式后置”,不再处于含糊占位状态;
|
||||
6. 关键数据算法具备基础自动化测试。
|
||||
|
||||
完成这些后,再进入 Spine JSON 烘焙导出会更稳妥,且无需反复修改当前已经定版的粒子属性与外观资源面板。
|
||||
+342
@@ -0,0 +1,342 @@
|
||||
# 网页粒子系统 · 阶段四
|
||||
|
||||
> 阶段定位:**修改器模块第一阶段定版**
|
||||
> 完成日期:2026-08-28
|
||||
> 工程目录:`/Users/tianmokeji/Desktop/SpineParticle`
|
||||
> 技术栈:Vue 3 + TypeScript + Pinia + PixiJS 7 + Vite
|
||||
|
||||
---
|
||||
|
||||
## 一、阶段结论
|
||||
|
||||
阶段四围绕粒子系统的外部运动影响完成开发,已经实现并接入实时预览的修改器包括:
|
||||
|
||||
1. 移动噪声;
|
||||
2. 重力;
|
||||
3. 风力;
|
||||
4. 全局力场;
|
||||
5. 吸附。
|
||||
|
||||
本阶段同时统一了场景初始状态和随机种子语义:打开项目时默认创建一个粒子系统,仅展开“场景对象”模块,其他模块保持折叠;所有修改器默认关闭。随机种子移动到场景对象模块,作为粒子系统全局随机入口,相同种子能够复现随机初始属性、资源选择和修改器采样结果。
|
||||
|
||||
阶段四结束后,项目已经具备“基础发射参数 + 外观资源 + 生命周期属性 + 多种运动修改器 + 画布辅助交互”的完整实时编辑链路。
|
||||
|
||||
---
|
||||
|
||||
## 二、初始状态与全局随机种子
|
||||
|
||||
### 2.1 默认场景状态
|
||||
|
||||
- 场景中默认创建一个名为 `ParticleSystem1` 的粒子系统;
|
||||
- “场景对象”模块默认展开;
|
||||
- 发射、形状、粒子属性、外观资源、修改器和导出等模块默认折叠;
|
||||
- 移动噪声、重力、风力、力场和吸附默认关闭;
|
||||
- 原有粒子属性、外观资源和时间轴功能保持不变。
|
||||
|
||||
### 2.2 随机种子
|
||||
|
||||
随机种子从“粒子属性”移动到“场景对象”,其影响范围扩展为整个粒子系统:
|
||||
|
||||
- 初始生命周期、速度、缩放和旋转的随机采样;
|
||||
- 图片资源的随机选择;
|
||||
- 重力和风力的粒子级随机强度;
|
||||
- 移动噪声的粒子相位;
|
||||
- 曲线吸附的弯曲方向。
|
||||
|
||||
包括 `0` 在内的任意整数种子都使用确定性随机序列。重新模拟相同配置时,可以获得一致的粒子结果。
|
||||
|
||||
---
|
||||
|
||||
## 三、移动噪声
|
||||
|
||||
移动噪声用于在不破坏基础物理轨迹的前提下,为粒子增加连续的视觉偏移。
|
||||
|
||||
| 参数 | 默认值 | 作用 |
|
||||
|---|---:|---|
|
||||
| 强度 | 30 | 控制噪声影响比例,范围 0~100% |
|
||||
| 波动范围 | 100 | 控制粒子偏离原轨迹的最大范围 |
|
||||
| 时间流速 | 1 | 控制噪声随时间变化的速度 |
|
||||
|
||||
实现规则:
|
||||
|
||||
- 噪声只作用于最终渲染位置,粒子的基础 `x/y` 和速度仍由物理系统维护;
|
||||
- 每个粒子使用由全局种子和粒子池编号推导的独立相位;
|
||||
- 出生时减去初始噪声采样,保证粒子仍从原发射点出现;
|
||||
- 使用连续正弦变化避免逐帧随机产生的跳动;
|
||||
- 反复关闭、开启且参数和种子相同时,轨迹保持可复现。
|
||||
|
||||
---
|
||||
|
||||
## 四、重力
|
||||
|
||||
重力支持固定强度、粒子级随机强度、方向和生命周期曲线。
|
||||
|
||||
### 4.1 默认值与方向
|
||||
|
||||
| 参数 | 默认值 |
|
||||
|---|---:|
|
||||
| 模式 | 固定 |
|
||||
| 重力强度 | 980 |
|
||||
| 方向 | -90° |
|
||||
| 生命周期曲线 | 全程 1 |
|
||||
|
||||
方向使用数学角度:
|
||||
|
||||
- `0°`:向右;
|
||||
- `-90°`:向屏幕下方;
|
||||
- 正强度沿设定方向作用;
|
||||
- 负强度使作用方向反转。
|
||||
|
||||
### 4.2 固定与随机
|
||||
|
||||
- 固定模式下,全部粒子使用当前固定强度;
|
||||
- 随机模式下,每个粒子出生时在 `min~max` 中独立采样;
|
||||
- 随机结果受全局随机种子控制;
|
||||
- 已出生粒子的随机强度保持不变。
|
||||
|
||||
### 4.3 生命周期内重力
|
||||
|
||||
生命周期曲线的横轴为归一化生命时间 `0~1`,纵轴为重力倍率 `0~1`。最终重力为:
|
||||
|
||||
```text
|
||||
最终重力 = 粒子重力强度 × 生命周期曲线倍率
|
||||
```
|
||||
|
||||
曲线支持关键点编辑和恢复默认水平曲线。
|
||||
|
||||
---
|
||||
|
||||
## 五、风力
|
||||
|
||||
风力复用重力的模式结构和生命周期曲线,但使用独立配置。
|
||||
|
||||
| 参数 | 默认值 |
|
||||
|---|---:|
|
||||
| 模式 | 固定 |
|
||||
| 风力强度 | 100 |
|
||||
| 方向 | 0°,水平向右 |
|
||||
| 生命周期曲线 | 全程 1 |
|
||||
|
||||
风力同样支持:
|
||||
|
||||
- 固定强度;
|
||||
- 每个粒子独立的随机强度范围;
|
||||
- `0°` 向右、`-90°` 向下的数学角度规则;
|
||||
- 负强度反转方向;
|
||||
- 生命周期 `0~1` 倍率曲线;
|
||||
- 全局随机种子复现。
|
||||
|
||||
---
|
||||
|
||||
## 六、全局力场
|
||||
|
||||
力场以场景全局坐标定义,不跟随粒子系统自身的位移、旋转和缩放。
|
||||
|
||||
### 6.1 参数
|
||||
|
||||
| 参数 | 默认值 | 说明 |
|
||||
|---|---:|---|
|
||||
| 中心 X/Y | 0 / 0 | 场景全局数学坐标 |
|
||||
| 力场强度 | 10 | 正数吸引,负数排斥 |
|
||||
| 力场半径 | 100 | 力场作用范围 |
|
||||
| 衰减模式 | 线性衰减 | 边缘强度为 0 |
|
||||
|
||||
### 6.2 衰减模式
|
||||
|
||||
1. **恒定**:半径范围内力度不变;
|
||||
2. **线性衰减**:中心最强,接近边缘时线性下降为 0;
|
||||
3. **距离反比**:距离中心越近力度越强,并设置中心上限以避免数值爆炸。
|
||||
|
||||
### 6.3 坐标与物理计算
|
||||
|
||||
每帧先将粒子从发射器局部坐标转换为场景全局数学坐标,计算力场加速度,再将加速度转换回粒子系统局部坐标。因此:
|
||||
|
||||
- 力场中心始终位于固定的场景位置;
|
||||
- 粒子系统经过位移、旋转或非等比缩放后,力场方向仍正确;
|
||||
- 正强度始终把粒子拉向中心;
|
||||
- 负强度始终把粒子推离中心。
|
||||
|
||||
### 6.4 画布交互
|
||||
|
||||
- 开启后在画布显示力场作用圆和中心标记;
|
||||
- 正吸力使用蓝色,负斥力使用红色;
|
||||
- 按住圆内任意位置即可拖动力场中心;
|
||||
- 拖动结果实时同步到面板中心 X/Y;
|
||||
- 圆形辅助线使用全局坐标,不应用粒子系统根变换。
|
||||
|
||||
---
|
||||
|
||||
## 七、吸附
|
||||
|
||||
吸附用于在延迟结束后,把粒子从当时所在位置移动到指定全局点;粒子进入吸附完成半径后立即死亡。
|
||||
|
||||
### 7.1 默认参数
|
||||
|
||||
| 参数 | 默认值 | 说明 |
|
||||
|---|---:|---|
|
||||
| 吸附点 X | 200 | 场景全局数学坐标 |
|
||||
| 吸附点 Y | 0 | 场景全局数学坐标 |
|
||||
| 吸附半径 | 20 | 进入范围即视为完成并死亡 |
|
||||
| 吸附时长 | 0.5 秒 | 从当前点移动到吸附点的时间 |
|
||||
| 延迟时间 | 0.1 秒 | 延迟结束后开始受吸附影响 |
|
||||
| 延迟方式 | 单粒子生命 | 每个粒子按出生时间独立计时 |
|
||||
| 吸附方式 | 曲线 | 使用确定性的二次曲线路径 |
|
||||
|
||||
吸附模块默认关闭。
|
||||
|
||||
### 7.2 延迟方式
|
||||
|
||||
| 模式 | 行为 |
|
||||
|---|---|
|
||||
| 统一时间 | 从吸附开关开启时统一计时;延迟结束后,当前及之后的粒子立即具备吸附条件 |
|
||||
| 单粒子生命 | 每个粒子从出生开始独立计时,达到延迟时间后才开始吸附 |
|
||||
|
||||
### 7.3 吸附路径
|
||||
|
||||
- **直线**:从吸附开始时的位置匀速插值到吸附点;
|
||||
- **曲线**:使用二次贝塞尔曲线移动,曲线弯曲方向由全局种子和粒子编号确定;
|
||||
- 吸附开始时记录粒子的实际当前位置,不会跳回出生点;
|
||||
- 拖动画布上的吸附点时,正在吸附的粒子会继续朝新的目标位置移动。
|
||||
|
||||
### 7.4 死亡规则
|
||||
|
||||
粒子在以下任一条件先满足时死亡:
|
||||
|
||||
1. 进入吸附半径;
|
||||
2. 吸附路径进度达到终点;
|
||||
3. 粒子自身生命周期耗尽。
|
||||
|
||||
生命周期判断优先于吸附计算,因此吸附不会延长粒子原本的生命。吸附半径可设为 `0`,此时目标表示一个点,粒子到达中心后死亡。
|
||||
|
||||
### 7.5 画布交互
|
||||
|
||||
- 开启后在画布显示红色吸附圆和中心十字;
|
||||
- 半径为 `0` 时显示红色中心点;
|
||||
- 可按住吸附圆拖动全局位置;
|
||||
- 半径为 `0` 时仍保留约 12 像素的鼠标命中区域,便于拖动;
|
||||
- 拖动结果实时同步到面板吸附点 X/Y;
|
||||
- 吸附圆与力场圆重叠时,红色吸附圆优先接收拖动。
|
||||
|
||||
---
|
||||
|
||||
## 八、修改器执行顺序
|
||||
|
||||
当前单帧主要执行顺序为:
|
||||
|
||||
1. 扣除粒子自身生命周期,生命耗尽则立即销毁;
|
||||
2. 计算重力;
|
||||
3. 计算风力;
|
||||
4. 计算全局力场;
|
||||
5. 应用阻尼;
|
||||
6. 积分基础位置;
|
||||
7. 判断吸附延迟并覆盖吸附路径位置;
|
||||
8. 计算移动噪声的渲染偏移;
|
||||
9. 更新缩放、旋转、透明度、颜色和混合模式;
|
||||
10. 生成实时状态或录制帧。
|
||||
|
||||
吸附开始后位置由吸附路径控制,并清除原速度,避免外力积累造成终点抖动。移动噪声仍属于渲染偏移,不改变吸附和物理系统维护的基础坐标。
|
||||
|
||||
---
|
||||
|
||||
## 九、核心实现文件
|
||||
|
||||
| 文件 | 本阶段职责 |
|
||||
|---|---|
|
||||
| `src/core/particleEmitter.ts` | 修改器配置、默认值、配置补齐、随机采样、重力/风力/力场/吸附/噪声计算 |
|
||||
| `src/views/ParticlePanel.vue` | 修改器开关、参数控件、模式选择与生命周期曲线 |
|
||||
| `src/views/Stage.vue` | 全局力场和吸附范围绘制、画布拖动、坐标换算及实时预览 |
|
||||
| `src/store/particleStore.ts` | 默认粒子系统和编辑器状态的承载 |
|
||||
|
||||
为兼容热更新对象和旧配置,`ensureEmitterConfig()` 会自动补齐新增字段。旧版重力、二维风力和力场参数也保留迁移处理。
|
||||
|
||||
---
|
||||
|
||||
## 十、验证结果
|
||||
|
||||
本阶段已完成以下检查:
|
||||
|
||||
- 相同种子下移动噪声重复开启可得到一致结果;
|
||||
- 重力方向 `0°` 向右、`-90°` 向下;
|
||||
- 重力和风力固定/随机模式能正确切换;
|
||||
- 生命周期倍率曲线已参与重力和风力计算;
|
||||
- 力场三种衰减模式可切换且配置实时生效;
|
||||
- 力场圆画布拖动与面板坐标同步;
|
||||
- 吸附默认参数、延迟模式、路径模式、死亡优先级均已接入运行逻辑;
|
||||
- `npm run build` 通过;
|
||||
- `git diff --check` 通过。
|
||||
|
||||
Vite 仍会提示主包体积超过 500 kB,该提示不影响当前功能运行。
|
||||
|
||||
---
|
||||
|
||||
## 十一、当前边界与已知风险
|
||||
|
||||
1. **拖尾尚未实现**:当前只有配置开关,没有轨迹采样和渲染逻辑。
|
||||
2. **碰撞尚未实现**:界面仍标记为预留,未定义碰撞体和反弹规则。
|
||||
3. **路径跟随尚未实现**:界面仍标记为预留,尚未建立路径编辑和粒子约束。
|
||||
4. **修改器组合缺少系统测试**:重力、风力、力场、吸附和噪声同时开启时,需要补充参数极值和稳定性测试。
|
||||
5. **吸附曲线暂为自动弧线**:当前不能由用户编辑控制点,也没有自定义吸附曲线编辑器。
|
||||
6. **辅助圆没有独立显示开关**:开启力场或吸附后,选中系统时始终显示对应辅助范围。
|
||||
7. **工程保存/加载尚未完成**:新增修改器字段目前依赖内存配置和默认值,尚未形成正式版本化工程格式。
|
||||
8. **缺少自动化单元测试**:当前主要依赖类型检查、生产构建和人工交互验证。
|
||||
|
||||
---
|
||||
|
||||
## 十二、下一步开发建议
|
||||
|
||||
### 优先级 P0:修改器模块收尾
|
||||
|
||||
建议按以下顺序继续,避免同时扩散多个高成本功能:
|
||||
|
||||
1. 拖尾:确定每粒子采样点上限、宽度/颜色/透明度生命周期和对象池;
|
||||
2. 碰撞:先支持全局直线、矩形和圆形碰撞体,再扩展反弹、摩擦和死亡;
|
||||
3. 路径跟随:复用吸附的全局坐标与曲线求值,增加可编辑路径控制点;
|
||||
4. 增加修改器总开关、辅助线显示开关和单项重置。
|
||||
|
||||
### 优先级 P1:工程保存与加载
|
||||
|
||||
- 定义 `ProjectSchema` 和 `schemaVersion`;
|
||||
- 将 Pixi 运行时对象与可序列化参数分离;
|
||||
- 保存全部修改器开关、模式、曲线和全局坐标;
|
||||
- 为阶段三、阶段四配置建立明确迁移函数;
|
||||
- 支持工程 JSON 或包含资源的 ZIP 工程包。
|
||||
|
||||
### 优先级 P2:修改器组合测试
|
||||
|
||||
- 为全局/局部坐标转换增加单元测试;
|
||||
- 验证旋转、负缩放、非等比缩放下的力场和吸附;
|
||||
- 测试极短生命周期、零延迟、零半径和极短吸附时长;
|
||||
- 测试正负极值、帧率波动和大粒子数量;
|
||||
- 固化相同种子的轨迹快照,防止随机语义回归。
|
||||
|
||||
### 优先级 P3:交互与可视化
|
||||
|
||||
- 力场和吸附中心增加悬停、选中和拖动反馈;
|
||||
- 为圆形辅助线增加半径拖拽手柄;
|
||||
- 在画布标注半径、强度方向和中心坐标;
|
||||
- 增加修改器辅助线显隐入口;
|
||||
- 处理力场、吸附点、根变换 Gizmo 重叠时的明确选中规则。
|
||||
|
||||
### 优先级 P4:性能与导出准备
|
||||
|
||||
- 避免每帧重复查找资源配置;
|
||||
- 为多个全局力场或碰撞体预留空间索引;
|
||||
- 明确修改器结果在固定帧录制中的采样精度;
|
||||
- 验证吸附死亡对粒子骨骼池和导出帧的影响;
|
||||
- 对 400 粒子上限进行组合修改器性能压测。
|
||||
|
||||
---
|
||||
|
||||
## 十三、阶段四验收标准
|
||||
|
||||
阶段四可视为完成,当以下条件持续满足:
|
||||
|
||||
- 默认场景和模块折叠状态符合约定;
|
||||
- 所有已实现修改器默认关闭;
|
||||
- 移动噪声受全局随机种子控制且可复现;
|
||||
- 重力和风力的固定、随机、方向与生命周期曲线真实生效;
|
||||
- 力场使用全局坐标并支持吸力、斥力、三种衰减和画布拖动;
|
||||
- 吸附支持全局点、完成半径、时长、两种延迟方式、两种路径和画布拖动;
|
||||
- 粒子进入吸附范围或自身生命周期结束时能够正确销毁;
|
||||
- 阶段三已经完成的粒子属性与外观资源功能没有发生回归;
|
||||
- TypeScript 类型检查和生产构建保持通过。
|
||||
Reference in New Issue
Block a user