From 2ad0b76d112e16ab96957c82b87c02e6669b71ae Mon Sep 17 00:00:00 2001 From: tianmo Date: Thu, 27 Aug 2026 18:28:46 +0800 Subject: [PATCH] =?UTF-8?q?=E7=B2=92=E5=AD=90=E5=B1=9E=E6=80=A7=E8=B0=83?= =?UTF-8?q?=E6=95=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.vue | 10 +- src/core/particleEmitter.ts | 162 +++++++++++++++-- src/style.css | 7 +- src/views/CurveEditor.vue | 58 ++++-- src/views/ParticleAttributeControl.vue | 106 +++++++++++ src/views/ParticlePanel.vue | 236 +++++++++++++++++++++++-- 6 files changed, 530 insertions(+), 49 deletions(-) create mode 100644 src/views/ParticleAttributeControl.vue diff --git a/src/App.vue b/src/App.vue index be4b8ab..eb8d8df 100644 --- a/src/App.vue +++ b/src/App.vue @@ -54,13 +54,13 @@ function onTimelineDrag(e: PointerEvent) { diff --git a/src/core/particleEmitter.ts b/src/core/particleEmitter.ts index 714ee27..417cf62 100644 --- a/src/core/particleEmitter.ts +++ b/src/core/particleEmitter.ts @@ -6,6 +6,12 @@ export type EmitMode = 'stream' | 'burst' export type EmitShape = 'point' | 'circle' | 'rect' | 'cone' | 'orbit' /** 混合模式 */ export type Blend = 'normal' | 'add' | 'multiply' | 'screen' +/** 粒子属性取值方式 */ +export type AttributeMode = 'fixed' | 'random' | 'curve' +/** 初始旋转额外支持跟随发射方向 */ +export type RotationMode = AttributeMode | 'direction' +export type ScaleAxisMode = 'uniform' | 'separate' +export interface CurvePoint { x: number; y: number } /** 粒子配置(引用保持,面板改字段即实时生效) */ export interface EmitterConfig { @@ -39,15 +45,47 @@ export interface EmitterConfig { // 属性 lifeMin: number lifeMax: number + lifeMode: AttributeMode + lifeCurve: CurvePoint[] speedMin: number speedMax: number + speedMode: AttributeMode + speedCurve: CurvePoint[] scaleMin: number scaleMax: number + scaleMode: AttributeMode + scaleAxisMode: ScaleAxisMode + scaleYMin: number + scaleYMax: number + scaleCurve: CurvePoint[] + scaleYCurve: CurvePoint[] scaleEndRatio: number + initialRotationMode: RotationMode + initialRotationMin: number + initialRotationMax: number + initialRotationCurve: CurvePoint[] alpha: number alphaEnd: number rotationSpeedMin: number rotationSpeedMax: number + // 生命周期内属性 + speedOverLifeEnabled: boolean + speedOverLifeMode: AttributeMode + speedOverLifeMin: number + speedOverLifeMax: number + speedOverLifeCurve: CurvePoint[] + scaleOverLifeEnabled: boolean + scaleOverLifeMode: AttributeMode + scaleOverLifeAxisMode: ScaleAxisMode + scaleOverLifeMin: number + scaleOverLifeMax: number + scaleYOverLifeMin: number + scaleYOverLifeMax: number + scaleOverLifeCurve: CurvePoint[] + scaleYOverLifeCurve: CurvePoint[] + rotationOverLifeEnabled: boolean + rotationOverLifeMode: AttributeMode + rotationOverLifeCurve: CurvePoint[] // 物理 gravity: number /** 阻尼(速度衰减系数,0=无) */ @@ -140,15 +178,46 @@ export function defaultConfig(): EmitterConfig { coneAngle: 60, lifeMin: 0.6, lifeMax: 1.8, + lifeMode: 'random', + lifeCurve: [{ x: 0, y: 0 }, { x: 1, y: 1 }], speedMin: 30, speedMax: 140, + speedMode: 'random', + speedCurve: [{ x: 0, y: 0 }, { x: 1, y: 1 }], scaleMin: 0.3, scaleMax: 0.8, + scaleMode: 'random', + scaleAxisMode: 'uniform', + scaleYMin: 0.3, + scaleYMax: 0.8, + scaleCurve: [{ x: 0, y: 0 }, { x: 1, y: 1 }], + scaleYCurve: [{ x: 0, y: 0 }, { x: 1, y: 1 }], scaleEndRatio: 0.3, + initialRotationMode: 'random', + initialRotationMin: 0, + initialRotationMax: 360, + initialRotationCurve: [{ x: 0, y: 0 }, { x: 1, y: 1 }], alpha: 0.9, alphaEnd: 0, rotationSpeedMin: -180, rotationSpeedMax: 180, + speedOverLifeEnabled: false, + speedOverLifeMode: 'fixed', + speedOverLifeMin: 1, + speedOverLifeMax: 1, + speedOverLifeCurve: [{ x: 0, y: 1 }, { x: 1, y: 1 }], + scaleOverLifeEnabled: false, + scaleOverLifeMode: 'curve', + scaleOverLifeAxisMode: 'uniform', + scaleOverLifeMin: 0, + scaleOverLifeMax: 1, + scaleYOverLifeMin: 0, + scaleYOverLifeMax: 1, + scaleOverLifeCurve: [{ x: 0, y: 1 }, { x: 1, y: 1 }], + scaleYOverLifeCurve: [{ x: 0, y: 1 }, { x: 1, y: 1 }], + rotationOverLifeEnabled: false, + rotationOverLifeMode: 'random', + rotationOverLifeCurve: [{ x: 0, y: 0 }, { x: 1, y: 1 }], gravity: 0, damping: 0, seed: 0, @@ -182,6 +251,19 @@ export function defaultConfig(): EmitterConfig { } } +/** 为热更新或旧版配置补齐后来新增的字段,避免 undefined 进入粒子计算。 */ +export function ensureEmitterConfig(config: EmitterConfig): EmitterConfig { + const defaults = defaultConfig() as unknown as Record + const target = config as unknown as Record + for (const [key, value] of Object.entries(defaults)) { + if (target[key] !== undefined && target[key] !== null) continue + target[key] = Array.isArray(value) + ? value.map((item) => typeof item === 'object' && item !== null ? { ...item } : item) + : value + } + return config +} + interface Particle { sprite: Sprite active: boolean @@ -190,9 +272,12 @@ interface Particle { maxLife: number x: number; y: number vx: number; vy: number - scaleStart: number; scaleEnd: number + scaleStartX: number; scaleStartY: number alphaStart: number; alphaEnd: number rotation: number; rotationSpeed: number + speedScale: number + speedScaleTarget: number + scaleOverLifeX: number; scaleOverLifeY: number } const BLEND_MAP: Record = { 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 @@
- +
0(透明)生命周期 →1(不透明)
@@ -226,10 +364,12 @@