增加涡流修改器

This commit is contained in:
tianmo committed 2026-09-22 15:58:28 +08:00
1 parent 3f2699a946
commit 5f3a4a33fe
10 files changed
+136 -7

No files matched your search

+1
View File
@@ -10,6 +10,7 @@
"build:connector": "node scripts/build-connector-app.mjs", "build:connector": "node scripts/build-connector-app.mjs",
"build:connector:legacy-sea": "node scripts/build-connector-sea.mjs", "build:connector:legacy-sea": "node scripts/build-connector-sea.mjs",
"test:loop-seam": "node scripts/verify-loop-seam.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:timeline": "node scripts/verify-timeline.mjs",
"test:spine-json": "node scripts/verify-spine-json.mjs", "test:spine-json": "node scripts/verify-spine-json.mjs",
"test:connector": "node scripts/verify-spine-connector.mjs", "test:connector": "node scripts/verify-spine-connector.mjs",
+27
View File
@@ -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('漩涡力方向、半径与三种衰减模式校验通过')
+6
View File
@@ -154,6 +154,12 @@ export function defaultConfig(): EmitterConfig {
forceCenterX: 0, forceCenterX: 0,
forceCenterY: 0, forceCenterY: 0,
forceFalloff: 'linear', forceFalloff: 'linear',
vortex: false,
vortexStrength: 10,
vortexRadius: 100,
vortexCenterX: 0,
vortexCenterY: 0,
vortexFalloff: 'linear',
attraction: false, attraction: false,
attractionCenterX: 200, attractionCenterX: 200,
attractionCenterY: 0, attractionCenterY: 0,
+14 -4
View File
@@ -6,7 +6,7 @@ import { pickImageResource, pickTrailResource, imageFrameState, imageFrameTextur
import type { SceneCollider, EmitterConfig, ParticleState } from './particleTypes' import type { SceneCollider, EmitterConfig, ParticleState } from './particleTypes'
import { defaultConfig, ensureEmitterConfig } from './particleConfig' import { defaultConfig, ensureEmitterConfig } from './particleConfig'
import { BLEND_MAP, MAX_PARTICLES } from './particleConstants' 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' export type * from './particleTypes'
@@ -258,9 +258,7 @@ export class ParticleEmitter extends Container {
const dy = cfg.forceCenterY - particleWorldY const dy = cfg.forceCenterY - particleWorldY
const distance = Math.hypot(dx, dy) const distance = Math.hypot(dx, dy)
if (distance > 0.0001 && distance <= cfg.forceRadius) { if (distance > 0.0001 && distance <= cfg.forceRadius) {
let falloff = 1 const falloff = fieldFalloff(cfg.forceFalloff, distance, cfg.forceRadius)
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 force = cfg.forceStrength * falloff * 10
const accelWorldX = dx / distance * force const accelWorldX = dx / distance * force
const accelWorldY = dy / distance * force const accelWorldY = dy / distance * force
@@ -270,6 +268,18 @@ export class ParticleEmitter extends Container {
p.vy += (-sinRoot * accelScreenX + cosRoot * accelScreenY) / rootScaleY * dt 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) { if (!p.collisionPaused && cfg.damping > 0) {
const f = Math.max(0, 1 - cfg.damping * dt) const f = Math.max(0, 1 - cfg.damping * dt)
+25 -1
View File
@@ -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 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 } 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 type ColliderHit = { x: number; y: number; nx: number; ny: number }
export function resolveColliderPoint(collider: SceneCollider, worldX: number, worldY: number): ColliderHit | null { export function resolveColliderPoint(collider: SceneCollider, worldX: number, worldY: number): ColliderHit | null {
const angle = collider.rotation * Math.PI / 180 const angle = collider.rotation * Math.PI / 180
+9 -1
View File
@@ -12,6 +12,7 @@ export type AttributeMode = 'fixed' | 'random' | 'curve'
/** 初始旋转额外支持跟随发射方向 */ /** 初始旋转额外支持跟随发射方向 */
export type RotationMode = AttributeMode | 'direction' export type RotationMode = AttributeMode | 'direction'
export type ScaleAxisMode = 'uniform' | 'separate' 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 CurvePoint { x: number; y: number; inX?: number; inY?: number; outX?: number; outY?: number }
export interface ColorGradientStop { position: number; color: string } export interface ColorGradientStop { position: number; color: string }
@@ -289,7 +290,14 @@ export interface EmitterConfig {
/** 力场中心使用场景全局数学坐标 */ /** 力场中心使用场景全局数学坐标 */
forceCenterX: number forceCenterX: number
forceCenterY: number forceCenterY: number
forceFalloff: 'constant' | 'linear' | 'inverse' forceFalloff: FieldFalloff
/** 开启漩涡力;正强度逆时针,负强度顺时针。 */
vortex: boolean
vortexStrength: number
vortexRadius: number
vortexCenterX: number
vortexCenterY: number
vortexFalloff: FieldFalloff
/** 开启吸附:粒子在延迟后从当前位置移动到全局吸附点,到达范围后死亡 */ /** 开启吸附:粒子在延迟后从当前位置移动到全局吸附点,到达范围后死亡 */
attraction: boolean attraction: boolean
attractionCenterX: number attractionCenterX: number
+5 -1
View File
@@ -28,7 +28,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { type BonePreview } from './stage/stageTypes' import { type BonePreview } from './stage/stageTypes'
import { drawAxis } from './stage/axisGuides' import { drawAxis } from './stage/axisGuides'
import { drawOrigin, drawShapeRange, drawForceField, drawAttraction } from './stage/emitterGuides' import { drawOrigin, drawShapeRange, drawForceField, drawVortex, drawAttraction } from './stage/emitterGuides'
import { drawBoneFollowTargets, drawGizmo, drawCollisionBodies, drawPaths } from './stage/sceneGuides' import { drawBoneFollowTargets, drawGizmo, drawCollisionBodies, drawPaths } from './stage/sceneGuides'
import { appendStateBones, drawBones } from './stage/boneGuides' import { appendStateBones, drawBones } from './stage/boneGuides'
@@ -84,6 +84,7 @@ let boneFollowTargetGfx: Graphics | null = null
let boneGfx: Graphics | null = null let boneGfx: Graphics | null = null
let shapeGfx: Graphics | null = null let shapeGfx: Graphics | null = null
let forceFieldGfx: Graphics | null = null let forceFieldGfx: Graphics | null = null
let vortexGfx: Graphics | null = null
let attractionGfx: Graphics | null = null let attractionGfx: Graphics | null = null
let collisionGfx: Graphics | null = null let collisionGfx: Graphics | null = null
let pathGfx: Graphics | null = null let pathGfx: Graphics | null = null
@@ -130,6 +131,8 @@ function createLayers() {
world.addChild(shapeGfx) world.addChild(shapeGfx)
forceFieldGfx = new Graphics() // 选中系统的全局力场范围 forceFieldGfx = new Graphics() // 选中系统的全局力场范围
world.addChild(forceFieldGfx) world.addChild(forceFieldGfx)
vortexGfx = new Graphics() // 选中系统的全局漩涡范围
world.addChild(vortexGfx)
attractionGfx = new Graphics() // 选中系统的全局吸附范围 attractionGfx = new Graphics() // 选中系统的全局吸附范围
world.addChild(attractionGfx) world.addChild(attractionGfx)
collisionGfx = new Graphics() collisionGfx = new Graphics()
@@ -435,6 +438,7 @@ function loop() {
drawOrigin(store, emitterPreviewTransform, originGfx) drawOrigin(store, emitterPreviewTransform, originGfx)
drawShapeRange(store, emitterPreviewTransform, shapeGfx) drawShapeRange(store, emitterPreviewTransform, shapeGfx)
drawForceField(store, forceFieldGfx) drawForceField(store, forceFieldGfx)
drawVortex(store, vortexGfx)
drawAttraction(store, attractionGfx) drawAttraction(store, attractionGfx)
drawGizmo(activeTool, interactions.selectedTransformTarget.bind(interactions), gizmoGfx) drawGizmo(activeTool, interactions.selectedTransformTarget.bind(interactions), gizmoGfx)
drawBones(collected, store, boneGfx) drawBones(collected, store, boneGfx)
@@ -82,6 +82,26 @@
<div class="modifier-hint">正数吸向中心 · 负数向外排斥 · 可在画布内拖动力场圆</div> <div class="modifier-hint">正数吸向中心 · 负数向外排斥 · 可在画布内拖动力场圆</div>
</div> </div>
</div> </div>
<div class="modifier-item">
<label class="switch-row modifier-switch">
<input type="checkbox" v-model="sys.config.vortex" />
<span class="switch-ui"></span><span>开启漩涡</span>
</label>
<div v-if="sys.config.vortex" class="modifier-params">
<NumSlider label="中心 X" :min="-5000" :max="5000" :step="1" v-model="sys.config.vortexCenterX" />
<NumSlider label="中心 Y" :min="-5000" :max="5000" :step="1" v-model="sys.config.vortexCenterY" />
<NumSlider label="力场强度" :min="-300" :max="300" :step="1" v-model="sys.config.vortexStrength" />
<NumSlider label="力场半径" :min="1" :max="2000" :step="1" v-model="sys.config.vortexRadius" />
<label class="row"><span>衰减模式</span>
<select v-model="sys.config.vortexFalloff" 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"> <div class="modifier-item">
<label class="switch-row modifier-switch"> <label class="switch-row modifier-switch">
<input type="checkbox" v-model="sys.config.attraction" /> <input type="checkbox" v-model="sys.config.attraction" />
@@ -326,6 +326,7 @@ const MODULE_RESET_KEYS: Record<Exclude<ResetModule, 'export'>, string[]> = {
'useGravity', 'gravity', 'gravityMode', 'gravityMin', 'gravityMax', 'gravityDirection', 'gravityCurve', 'useGravity', 'gravity', 'gravityMode', 'gravityMin', 'gravityMax', 'gravityDirection', 'gravityCurve',
'wind', 'windX', 'windY', 'windMode', 'windMin', 'windMax', 'windDirection', 'windCurve', 'wind', 'windX', 'windY', 'windMode', 'windMin', 'windMax', 'windDirection', 'windCurve',
'forceField', 'forceStrength', 'forceRadius', 'forceCenterX', 'forceCenterY', 'forceFalloff', 'forceField', 'forceStrength', 'forceRadius', 'forceCenterX', 'forceCenterY', 'forceFalloff',
'vortex', 'vortexStrength', 'vortexRadius', 'vortexCenterX', 'vortexCenterY', 'vortexFalloff',
'attraction', 'attractionCenterX', 'attractionCenterY', 'attractionRadius', 'attraction', 'attractionCenterX', 'attractionCenterY', 'attractionRadius',
'attractionDuration', 'attractionDelay', 'attractionDelayMode', 'attractionPath', 'attractionDuration', 'attractionDelay', 'attractionDelayMode', 'attractionPath',
'trail', 'trailConfigVersion', 'trailResources', 'trailResourceVersion', 'trail', 'trailConfigVersion', 'trailResources', 'trailResourceVersion',
+28
View File
@@ -132,6 +132,34 @@ export function drawForceField(store: EditorStore, forceFieldGfx: Graphics | nul
g.endFill() g.endFill()
} }
/** 绘制当前选中系统的全局漩涡范围与旋转方向。 */
export function drawVortex(store: EditorStore, vortexGfx: Graphics | null) {
if (!vortexGfx) return
const g = vortexGfx
g.clear()
const sys = store.systems.find((item) => item.id === store.activeId)
if (sys?.visible === false) return
const cfg = sys?.config
if (!cfg?.vortex || cfg.vortexRadius <= 0) return
const x = cfg.vortexCenterX
const y = -cfg.vortexCenterY
const color = cfg.vortexStrength >= 0 ? 0xb97cff : 0xffa24c
g.lineStyle(2, color, 0.9)
g.beginFill(color, 0.07)
g.drawCircle(x, y, cfg.vortexRadius)
g.endFill()
g.beginFill(color, 0.95)
g.drawCircle(x, y, 4)
g.endFill()
const markerX = x + Math.min(cfg.vortexRadius * 0.65, 50)
const direction = cfg.vortexStrength >= 0 ? -1 : 1
const endY = y + direction * 14
g.lineStyle(2, color, 0.9)
g.moveTo(markerX, y + direction * -8); g.lineTo(markerX, endY)
g.moveTo(markerX, endY); g.lineTo(markerX - 4, endY - direction * 5)
g.moveTo(markerX, endY); g.lineTo(markerX + 4, endY - direction * 5)
}
/** 绘制全局吸附点与吸附完成半径;半径为 0 时显示为红色点。 */ /** 绘制全局吸附点与吸附完成半径;半径为 0 时显示为红色点。 */
export function drawAttraction(store: EditorStore, attractionGfx: Graphics | null) { export function drawAttraction(store: EditorStore, attractionGfx: Graphics | null) {
if (!attractionGfx) return if (!attractionGfx) return