完成阶段3-4

This commit is contained in:
tianmo
2026-08-28 16:06:28 +08:00
parent f0f4104b0c
commit 4e2e123269
5 changed files with 1158 additions and 50 deletions
+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))
+131 -21
View File
@@ -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>纵轴强度 01</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>纵轴强度 01</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>
+131
View File
@@ -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 }