= { normal: 0, add: 1, multiply: 2, screen: 3 }
@@ -215,7 +300,7 @@ export class ParticleEmitter extends Container {
constructor(config: EmitterConfig, maxParticles = MAX_PARTICLES) {
super()
- this.cfg = config
+ this.cfg = ensureEmitterConfig(config)
this._ensurePool(maxParticles)
}
@@ -241,7 +326,8 @@ export class ParticleEmitter extends Container {
this.pool.push({
sprite, active: false, boneName: 'p_' + this.pool.length,
life: 0, maxLife: 1, x: 0, y: 0, vx: 0, vy: 0,
- scaleStart: 1, scaleEnd: 1, alphaStart: 1, alphaEnd: 1, rotation: 0, rotationSpeed: 0,
+ scaleStartX: 1, scaleStartY: 1, alphaStart: 1, alphaEnd: 1, rotation: 0, rotationSpeed: 0,
+ speedScale: 1, speedScaleTarget: 1, scaleOverLifeX: 1, scaleOverLifeY: 1,
})
}
}
@@ -250,7 +336,14 @@ export class ParticleEmitter extends Container {
/** 每帧更新;返回活动粒子状态快照(与骨骼一一对应) */
update(dt: number): ParticleState[] {
+ // HMR 会保留已创建的系统对象;旧对象缺少新增字段时在这里即时补齐。
const cfg = this.cfg
+ if (
+ 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)
+ ) ensureEmitterConfig(cfg)
this.reseed() // 种子变化即重建随机源
// 累计发射时间
this._elapsed += dt
@@ -298,11 +391,29 @@ export class ParticleEmitter extends Container {
}
p.x += p.vx * dt
p.y += p.vy * dt
- p.rotation += p.rotationSpeed * dt
const t = 1 - p.life / p.maxLife
const s = p.sprite
+
+ if (cfg.speedOverLifeEnabled) {
+ const target = finite(overLifeValue(cfg.speedOverLifeMode, cfg.speedOverLifeMin, cfg.speedOverLifeMax, cfg.speedOverLifeCurve, t, p.speedScaleTarget), 1)
+ const ratio = p.speedScale === 0 ? target : target / p.speedScale
+ p.vx *= ratio; p.vy *= ratio; p.speedScale = target
+ }
+
+ let rotationSpeed = p.rotationSpeed
+ if (cfg.rotationOverLifeEnabled && cfg.rotationOverLifeMode === 'curve') {
+ 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
- s.scale.set(p.scaleStart + (p.scaleEnd - p.scaleStart) * t)
+ let scaleMulX = 1, scaleMulY = 1
+ if (cfg.scaleOverLifeEnabled) {
+ scaleMulX = overLifeValue(cfg.scaleOverLifeMode, cfg.scaleOverLifeMin, cfg.scaleOverLifeMax, cfg.scaleOverLifeCurve, t, p.scaleOverLifeX)
+ scaleMulY = cfg.scaleOverLifeAxisMode === 'separate'
+ ? overLifeValue(cfg.scaleOverLifeMode, cfg.scaleYOverLifeMin, cfg.scaleYOverLifeMax, cfg.scaleYOverLifeCurve, t, p.scaleOverLifeY)
+ : scaleMulX
+ }
+ s.scale.set(finite(p.scaleStartX * scaleMulX, 1), finite(p.scaleStartY * scaleMulY, 1))
s.alpha = cfg.alphaMode === 'curve' ? curveAt(cfg.alphaCurve, t) * cfg.alphaScale : (p.alphaStart + (p.alphaEnd - p.alphaStart) * t) * cfg.alphaScale
const rgb = cfg.colorOverLife ? blendRgb(cfg.colorStart, cfg.colorEnd, t) : hexToNumber(cfg.colorStart)
s.tint = rgb
@@ -340,8 +451,8 @@ export class ParticleEmitter extends Container {
let idx = this.pool.findIndex((p) => !p.active)
if (idx === -1) return // 池满
const { x: ox, y: oy } = this.spawnOrigin()
- const life = lerp(cfg.lifeMin, cfg.lifeMax, this.rng())
- const speed = lerp(cfg.speedMin, cfg.speedMax, this.rng())
+ const life = initialValue(cfg.lifeMode, cfg.lifeMin, cfg.lifeMax, cfg.lifeCurve, this.rng())
+ const speed = initialValue(cfg.speedMode, cfg.speedMin, cfg.speedMax, cfg.speedCurve, this.rng())
// 锥体:发射方向用锥形开口角度(coneAngle)散布,且 y 朝数学上正(与扇区绘制一致);其余形状沿用 spread + 屏幕坐标
let dirAng: number
if (cfg.shape === 'cone') {
@@ -354,10 +465,27 @@ export class ParticleEmitter extends Container {
p.x = ox; p.y = oy
p.vx = Math.cos(dirAng) * speed
p.vy = (cfg.shape === 'cone' ? -Math.sin(dirAng) : Math.sin(dirAng)) * speed
- p.scaleStart = lerp(cfg.scaleMin, cfg.scaleMax, this.rng())
- p.scaleEnd = p.scaleStart * cfg.scaleEndRatio
+ p.scaleStartX = initialValue(cfg.scaleMode, cfg.scaleMin, cfg.scaleMax, cfg.scaleCurve, this.rng())
+ p.scaleStartY = cfg.scaleAxisMode === 'separate'
+ ? initialValue(cfg.scaleMode, cfg.scaleYMin, cfg.scaleYMax, cfg.scaleYCurve, this.rng())
+ : p.scaleStartX
p.alphaStart = cfg.alpha; p.alphaEnd = cfg.alphaEnd
- p.rotation = this.rng() * 360; p.rotationSpeed = lerp(cfg.rotationSpeedMin, cfg.rotationSpeedMax, this.rng())
+ p.rotation = cfg.initialRotationMode === 'direction'
+ ? -Math.atan2(p.vy, p.vx) * 180 / Math.PI + cfg.initialRotationMin
+ : initialValue(cfg.initialRotationMode, cfg.initialRotationMin, cfg.initialRotationMax, cfg.initialRotationCurve, this.rng())
+ p.rotationSpeed = cfg.rotationOverLifeEnabled
+ ? initialValue(cfg.rotationOverLifeMode, cfg.rotationSpeedMin, cfg.rotationSpeedMax, cfg.rotationOverLifeCurve, this.rng())
+ : 0
+ p.speedScale = 1
+ p.speedScaleTarget = cfg.speedOverLifeMode === 'random'
+ ? lerp(cfg.speedOverLifeMin, cfg.speedOverLifeMax, this.rng())
+ : cfg.speedOverLifeMin
+ p.scaleOverLifeX = cfg.scaleOverLifeMode === 'random'
+ ? lerp(cfg.scaleOverLifeMin, cfg.scaleOverLifeMax, this.rng())
+ : cfg.scaleOverLifeMin
+ p.scaleOverLifeY = cfg.scaleOverLifeMode === 'random'
+ ? lerp(cfg.scaleYOverLifeMin, cfg.scaleYOverLifeMax, this.rng())
+ : cfg.scaleYOverLifeMin
p.sprite.tint = hexToNumber(cfg.colorStart)
p.sprite.visible = true
}
@@ -421,9 +549,19 @@ export class ParticleEmitter extends Container {
}
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 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))
+ return lerp(min, max, random)
+}
+function overLifeValue(mode: AttributeMode, min: number, max: number, curve: CurvePoint[], age: number, sampled: number) {
+ if (mode === 'curve') return lerp(min, max, curveAt(curve, age))
+ return mode === 'random' ? sampled : min
+}
/** 在透明度曲线关键帧上按 x(生命周期时刻 0~1)求 y(透明度 0~1)。保证有序、端点外取端点 */
-function curveAt(pts: { x: number; y: number }[], x: number): number {
- if (!pts || pts.length === 0) return 1
+function curveAt(pts: CurvePoint[] | undefined, x: number): number {
+ if (!Array.isArray(pts) || pts.length === 0) return 1
const p = [...pts].sort((a, b) => a.x - b.x)
if (x <= p[0].x) return p[0].y
if (x >= p[p.length - 1].x) return p[p.length - 1].y
diff --git a/src/style.css b/src/style.css
index e80c9d1..98cb6e4 100644
--- a/src/style.css
+++ b/src/style.css
@@ -1,9 +1,14 @@
html, body {
+ width: 100%;
+ height: 100%;
margin: 0;
padding: 0;
+ overflow: clip;
+ overscroll-behavior: none;
background: #0b0b12;
color: #e8e8f0;
font-family: system-ui, -apple-system, 'Segoe UI', sans-serif;
}
+body { position: fixed; inset: 0; }
* { box-sizing: border-box; }
-#app { width: 100%; height: 100vh; }
+#app { position: fixed; inset: 0; width: auto; height: auto; overflow: clip; }
diff --git a/src/views/CurveEditor.vue b/src/views/CurveEditor.vue
index 750c9a8..cb9a9f0 100644
--- a/src/views/CurveEditor.vue
+++ b/src/views/CurveEditor.vue
@@ -1,29 +1,39 @@
-
-
双击空白新建·双击节点删除·拖动节点调整
+
+
+
双击空白新建·双击节点删除·拖动节点调整
+
+
diff --git a/src/views/ParticlePanel.vue b/src/views/ParticlePanel.vue
index 8532874..7bdcd70 100644
--- a/src/views/ParticlePanel.vue
+++ b/src/views/ParticlePanel.vue
@@ -97,15 +97,153 @@
{{ collapsed.attr ? '▸' : '▾' }}粒子属性
-
-
-
-
-
-
-
-
-
+
+
粒子初始参数
+
+
+
+
+
+
+
+
+
+
+
+
粒子生命周期变化参数
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -131,7 +269,7 @@
@@ -226,10 +364,12 @@