diff --git a/package.json b/package.json index 3f1b2f1..4b869df 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "build:connector": "node scripts/build-connector-app.mjs", "build:connector:legacy-sea": "node scripts/build-connector-sea.mjs", "test:loop-seam": "node scripts/verify-loop-seam.mjs", + "test:vortex": "node scripts/verify-vortex.mjs", "test:timeline": "node scripts/verify-timeline.mjs", "test:spine-json": "node scripts/verify-spine-json.mjs", "test:connector": "node scripts/verify-spine-connector.mjs", diff --git a/scripts/verify-vortex.mjs b/scripts/verify-vortex.mjs new file mode 100644 index 0000000..769fbae --- /dev/null +++ b/scripts/verify-vortex.mjs @@ -0,0 +1,27 @@ +import { build } from 'esbuild' + +const bundled = await build({ + bundle: true, + stdin: { + contents: "export { fieldFalloff, vortexAcceleration } from './src/core/particleMath.ts'", + resolveDir: process.cwd(), + sourcefile: 'vortex-test-entry.ts', + }, + format: 'esm', + platform: 'node', + write: false, +}) +const source = bundled.outputFiles[0].text +const api = await import(`data:text/javascript;base64,${Buffer.from(source).toString('base64')}`) + +if (api.fieldFalloff('constant', 50, 100) !== 1) throw new Error('恒定衰减计算错误') +if (api.fieldFalloff('linear', 50, 100) !== 0.5) throw new Error('线性衰减计算错误') +if (api.fieldFalloff('inverse', 5, 100) !== 10) throw new Error('距离反比衰减计算错误') +if (api.fieldFalloff('constant', 101, 100) !== 0) throw new Error('漩涡范围外不应有力') + +const counterclockwise = api.vortexAcceleration(10, 0, 0, 0, 2, 100, 'constant') +if (counterclockwise.x !== 0 || counterclockwise.y !== 20) throw new Error('正强度应产生逆时针切向力') +const clockwise = api.vortexAcceleration(10, 0, 0, 0, -2, 100, 'constant') +if (clockwise.x !== 0 || clockwise.y !== -20) throw new Error('负强度应产生顺时针切向力') + +console.log('漩涡力方向、半径与三种衰减模式校验通过') diff --git a/src/core/particleConfig.ts b/src/core/particleConfig.ts index 8410bac..b3cc31f 100644 --- a/src/core/particleConfig.ts +++ b/src/core/particleConfig.ts @@ -154,6 +154,12 @@ export function defaultConfig(): EmitterConfig { forceCenterX: 0, forceCenterY: 0, forceFalloff: 'linear', + vortex: false, + vortexStrength: 10, + vortexRadius: 100, + vortexCenterX: 0, + vortexCenterY: 0, + vortexFalloff: 'linear', attraction: false, attractionCenterX: 200, attractionCenterY: 0, diff --git a/src/core/particleEmitter.ts b/src/core/particleEmitter.ts index 5972dec..0954389 100644 --- a/src/core/particleEmitter.ts +++ b/src/core/particleEmitter.ts @@ -6,7 +6,7 @@ import { pickImageResource, pickTrailResource, imageFrameState, imageFrameTextur import type { SceneCollider, EmitterConfig, ParticleState } from './particleTypes' import { defaultConfig, ensureEmitterConfig } from './particleConfig' import { BLEND_MAP, MAX_PARTICLES } from './particleConstants' -import { lerp, finite, localToWorldMath, worldMathToLocal, localVelocityToWorldMath, worldVelocityToLocalMath, resolveColliderPoint, tFromLife, seededUnit, initialValue, overLifeValue, curveAt, mulberry32, hexToNumber, gradientColorAt } from './particleMath' +import { lerp, finite, localToWorldMath, worldMathToLocal, localVelocityToWorldMath, worldVelocityToLocalMath, resolveColliderPoint, tFromLife, seededUnit, initialValue, overLifeValue, curveAt, mulberry32, hexToNumber, gradientColorAt, fieldFalloff, vortexAcceleration } from './particleMath' // 保留原有入口,现有调用方无需随内部拆分修改。 export type * from './particleTypes' @@ -258,9 +258,7 @@ export class ParticleEmitter extends Container { 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 falloff = fieldFalloff(cfg.forceFalloff, distance, cfg.forceRadius) const force = cfg.forceStrength * falloff * 10 const accelWorldX = dx / distance * force const accelWorldY = dy / distance * force @@ -270,6 +268,18 @@ export class ParticleEmitter extends Container { p.vy += (-sinRoot * accelScreenX + cosRoot * accelScreenY) / rootScaleY * dt } } + // 漩涡力在全局数学坐标中沿半径切线方向作用。 + if (!p.collisionPaused && cfg.vortex && cfg.vortexStrength !== 0 && cfg.vortexRadius > 0) { + const world = localToWorldMath(cfg, p.x, p.y, this.simulationTransform) + const acceleration = vortexAcceleration( + world.x, world.y, + cfg.vortexCenterX, cfg.vortexCenterY, + cfg.vortexStrength, cfg.vortexRadius, cfg.vortexFalloff, + ) + const localAcceleration = worldVelocityToLocalMath(cfg, acceleration.x, acceleration.y, this.simulationTransform) + p.vx += localAcceleration.x * dt + p.vy += localAcceleration.y * dt + } // 阻尼:速度按衰减系数逐渐减小 if (!p.collisionPaused && cfg.damping > 0) { const f = Math.max(0, 1 - cfg.damping * dt) diff --git a/src/core/particleMath.ts b/src/core/particleMath.ts index 384ea3b..26f9494 100644 --- a/src/core/particleMath.ts +++ b/src/core/particleMath.ts @@ -1,4 +1,4 @@ -import type { EmitMode, EmitShape, Blend, AttributeMode, RotationMode, ScaleAxisMode, CurvePoint, ColorGradientStop, ColliderShape, CollisionResponse, SceneCollider, ParticleImageResource, ParticleSequenceFrame, TrailImageResource, EmitterConfig, ParticleState, TrailBoneState, TrailState } from './particleTypes' +import type { EmitMode, EmitShape, Blend, AttributeMode, RotationMode, ScaleAxisMode, CurvePoint, ColorGradientStop, ColliderShape, CollisionResponse, SceneCollider, ParticleImageResource, ParticleSequenceFrame, TrailImageResource, EmitterConfig, ParticleState, TrailBoneState, TrailState, FieldFalloff } from './particleTypes' export function lerp(a: number, b: number, t: number) { return a + (b - a) * t } export function finite(value: number, fallback: number) { return Number.isFinite(value) ? value : fallback } @@ -47,6 +47,30 @@ export function worldVelocityToLocalMath(cfg: EmitterConfig, vx: number, vy: num } } +export function fieldFalloff(mode: FieldFalloff, distance: number, radius: number) { + if (radius <= 0 || distance < 0 || distance > radius) return 0 + if (mode === 'linear') return 1 - distance / radius + if (mode === 'inverse') return Math.min(10, radius / Math.max(distance, radius * 0.1)) + return 1 +} + +export function vortexAcceleration( + worldX: number, + worldY: number, + centerX: number, + centerY: number, + strength: number, + radius: number, + falloffMode: FieldFalloff, +) { + const dx = worldX - centerX + const dy = worldY - centerY + const distance = Math.hypot(dx, dy) + if (distance <= 0.0001 || distance > radius) return { x: 0, y: 0 } + const force = strength * fieldFalloff(falloffMode, distance, radius) * 10 + return { x: -dy / distance * force, y: dx / distance * force } +} + export type ColliderHit = { x: number; y: number; nx: number; ny: number } export function resolveColliderPoint(collider: SceneCollider, worldX: number, worldY: number): ColliderHit | null { const angle = collider.rotation * Math.PI / 180 diff --git a/src/core/particleTypes.ts b/src/core/particleTypes.ts index 06b553d..e96bb31 100644 --- a/src/core/particleTypes.ts +++ b/src/core/particleTypes.ts @@ -12,6 +12,7 @@ export type AttributeMode = 'fixed' | 'random' | 'curve' /** 初始旋转额外支持跟随发射方向 */ export type RotationMode = AttributeMode | 'direction' export type ScaleAxisMode = 'uniform' | 'separate' +export type FieldFalloff = 'constant' | 'linear' | 'inverse' export interface CurvePoint { x: number; y: number; inX?: number; inY?: number; outX?: number; outY?: number } export interface ColorGradientStop { position: number; color: string } @@ -289,7 +290,14 @@ export interface EmitterConfig { /** 力场中心使用场景全局数学坐标 */ forceCenterX: number forceCenterY: number - forceFalloff: 'constant' | 'linear' | 'inverse' + forceFalloff: FieldFalloff + /** 开启漩涡力;正强度逆时针,负强度顺时针。 */ + vortex: boolean + vortexStrength: number + vortexRadius: number + vortexCenterX: number + vortexCenterY: number + vortexFalloff: FieldFalloff /** 开启吸附:粒子在延迟后从当前位置移动到全局吸附点,到达范围后死亡 */ attraction: boolean attractionCenterX: number diff --git a/src/views/Stage.vue b/src/views/Stage.vue index 0656fc7..90949a9 100644 --- a/src/views/Stage.vue +++ b/src/views/Stage.vue @@ -28,7 +28,7 @@