最近在做一套动作游戏的技能系统,需要实现一个以释放点为中心、向四周瞬间扩散、并把敌人炸飞的冲击波技能。翻来覆去折腾了几个晚上,最终回到了 Unity 物理系统自带的 Physics.OverlapSphere 上。这个东西说白了就是一次球形范围检测:给定圆心、半径,一帧之内把所有撞进这个球里的 Collider 全部捞出来,然后你对这些对象做伤害、做推力、做特效,冲击波的效果就立住了。这篇文章我就把这几天踩过的坑、调过的参数、最终整理出来的一套可复用方案完整记录下来,思路和代码都会给到,适合正在做技能系统、爆炸物、道具交互或者 Boss 范围技能的 Unity 开发者参考。
1. 先把“冲击波”这三个字拆成可执行的技术需求
很多人做冲击波,第一反应是去搞视觉特效——扩散光环、粒子爆开、地面尘土。但这些只是表现层,冲击波这个玩法需求真正硬核的部分,是一个空间查询问题:以某个点为中心,在某一瞬间拿到半径范围内的所有对象,然后对这些对象统一施加伤害和物理力。Unity 里专职干这件事的 API 就是 Physics.OverlapSphere。
1.1 为什么偏偏是 OverlapSphere,而不是 Trigger 或 SphereCast
我做这套技能之前,也试过几种别的实现路径,逐个说下取舍。
一是 OnTriggerEnter / OnTriggerExit。它是持续检测的思路,需要先在场景里挂一个球形 Collider,等物体进进出出。冲击波是一次性的爆发技能,我不想为了它常驻一个触发体,而且 Trigger 的进出事件跟技能释放时机很难同步,尤其是瞬发技能,很容易出现 Collider 还没生成完、该打的人已经跑掉了的情况。
二是 Physics.SphereCast。它是沿着一条射线方向去扫,适合激光、子弹、穿刺这类定向攻击。冲击波是三百六十度全方向扩散,SphereCast 一次只能处理一个方向,想铺满整个球面你得转着圈射好几根射线,效率和精度都很难看。
三是自己手动遍历场景里所有物体,用 Vector3.Distance 跟半径比较。小 Demo 里确实能跑,但项目里场景物体一多,每帧全量遍历的开销就上来了,而且这种纯 CPU 计算完全绕过了物理引擎的碰撞管理,得不偿失。除非你的目标集合已经预先维护好,否则不建议。
最终选定 Physics.OverlapSphere,是因为它的几何语义和冲击波天然匹配:一个点,一个半径,一次调用,返回所有范围内的 Collider。物理引擎自己已经做了空间划分(内部有宽相阶段的加速结构),查询效率有保障。它天生就是干这个的。
1.2 OverlapSphere 的两个版本,性能差别比想象中大
Unity 的这个 API 有两个重载版本,新手很容易忽略。
第一个是基础版本,返回 Collider[]:
Collider[] hits = Physics.OverlapSphere(center, radius);第二个是 NonAlloc 版本,把结果写入你预先准备好的数组:
int count = Physics.OverlapSphereNonAlloc(center, radius, buffer);基础版本每次调用都会在托管堆上分配一个新的数组。爆炸技能本身调用频率不高,偶尔用一次问题不大,但如果你做的是连发技能、多段爆炸、或者场景里同时有好几个冲击波在释放,频繁分配数组的话,GC 压力会明显上来。实测下来,同样的爆炸连发场景,从 OverlapSphere 换成 NonAlloc 版本之后,一次角色技能连招打完,GC 分配量能降一大截。
所以我的建议很直接:就算你只做一次爆炸,也养成用 NonAlloc 版本的习惯。多写一个数组的代码量几乎为零,但换来了长期稳定的性能表现。
2. OverlapSphere 的核心参数,每一个都得掰扯清楚
这个 API 的完整签名长这样:
public static Collider[] OverlapSphere( Vector3 position, float radius, int layerMask = -1, QueryTriggerInteraction queryTriggerInteraction = QueryTriggerInteraction.UseGlobal );每个参数都有讲究,特别是后面这两个,很多坑都是从这里出来的。
2.1 LayerMask 不是 Layer Index,是位掩码
Unity 里每个 GameObject 可以指定一个 Layer,从 0 到 31,一共 32 层。LayerMask 这个东西虽然名字里带 Layer,但它存的不是“第几层”这个整数,而是一个 32 位的整型位掩码,每一位对应一个 Layer。也就是说,如果你想把第 6 层纳入检测范围,不能写:
// 这是错的,int 6 表示的是第 1 层和第 2 层这两位,不是第 6 层 int mask = 6;正确写法是:
int mask = 1 << 6;或者干脆用 Unity 提供的工具方法:
int mask = LayerMask.GetMask("Enemy", "Obstacle");我在项目里最省事的做法,是声明一个 public LayerMask 字段。这样 Inspector 面板会直接显示一个多选框,美术和策划不用碰代码就能配置哪些层能被冲击波命中,不容易出错。
public LayerMask targetMask;用的时候直接传给 OverlapSphere 就行。
注意:这里踩过一个很深的坑。有次我图省事,直接在代码里传了 6 这个整数,结果所有敌人一个都炸不到,反而场景里的地面和墙体全被炸飞了。原因就是 6 这个值的二进制是 000110,对应的是第 1 层和第 2 层,而当时场景里地面刚好在第 2 层。物理检测参数的配置问题,后来我基本都是用 Inspector 拖选加打印日志双重确认,不再凭记忆写数字。
2.2 QueryTriggerInteraction:Trigger 到底算不算目标
这个参数决定 OverlapSphere 是否返回带 Trigger 的 Collider。
默认值是 UseGlobal,也就是跟随 Project Settings 里 Physics 面板的 Queries Hit Triggers 开关。很多项目这个开关是关着的,所以 OverlapSphere 默认检测不到 Is Trigger 的 Collider。
这对应一个很实际的需求反差:冲击波技能很多时候需要命中的目标,比如陷阱区域、传送门、可交互 NPC,它们的碰撞体就设成了 Trigger。如果你用默认参数去查,这些对象会全部漏掉。
处理方式是在调用时显式指定:
Physics.OverlapSphereNonAlloc(center, radius, buffer, targetMask, QueryTriggerInteraction.Collide);反过来,如果你不希望冲击波触发那些纯 Trigger 的区域(比如技能指示器范围圈),就传 QueryTriggerInteraction.Ignore。这个参数别看它不起眼,配置错了经常出现“明明在范围内却打不到东西”的诡异问题。
2.3 NonAlloc 缓冲数组的容量设计
NonAlloc 版本需要你自备一个 Collider 数组。这里有个很容易被忽略的点:如果场景里实际命中的 Collider 数量超过了数组长度,多出来的对象不会被报错,而是被直接丢弃。
我第一次用的时候数组开的 32,然后一个爆炸炸了 40 个碎片对象,最后只有前 32 个有反应,排查了好久才发现是缓冲区不够。
给个经验值:角色技能冲击波,32 到 64 基本够用;如果是爆炸桶连锁引爆、场景破坏之类动辄上百碎片的场景,直接用 128 甚至 256。数组里存的是引用,不是结构体拷贝,开大一点的内存代价可以接受,换来的是不会“悄悄吞对象”的安心感。
private Collider[] buffer = new Collider[128];2.4 返回值里的 Collider 没有排序,需要自己处理
OverlapSphere 返回的 Collider 数组,顺序是不保证的。它取决于物理引擎内部的空间划分和碰撞体存储顺序,不是你脑子里以为的“从近到远”。
所以如果你想做距离衰减的伤害,不要指望数组第一个就是离爆炸中心最近的物体。正确做法是在拿到结果之后,自己遍历一次,用 Vector3.Distance 或者 (其他物体位置 - 中心点).sqrMagnitude 去做排序或者逐个体积衰减计算。后面第三部分我会给出具体的衰减处理代码。
3. 徒手写一个可复用的冲击波工具类
这一块才是正文。我把工具类拆成几层:检测层负责捞 Collider、衰减层负责算倍数、施力层负责推物体、事件层负责通知外部表现。每层各干各的,后续维护起来不头大。
3.1 完整的冲击波工具类代码
using System; using System.Collections.Generic; using UnityEngine; public class ImpactWave : MonoBehaviour { [Header("范围与伤害")] public float radius = 10f; public float baseDamage = 50f; public AnimationCurve falloffCurve = AnimationCurve.Linear(0f, 1f, 1f, 0f); [Header("物理推力")] public float pushForce = 30f; public ForceMode forceMode = ForceMode.Impulse; public float upwardModifier = 0.2f; [Header("检测配置")] public LayerMask targetMask = -1; public bool hitTriggers = false; public bool obstacleCheck = false; public LayerMask obstacleMask = -1; public int bufferSize = 128; private Collider[] _buffer; private List<Collider> _targets = new List<Collider>(64); private RaycastHit[] _obstacleHit = new RaycastHit[1]; public event Action<Collider, float> OnWaveHit; public event Action<Vector3> OnWaveFired; private void Awake() { _buffer = new Collider[bufferSize]; } public void Fire(Vector3 center) { _targets.Clear(); QueryTriggerInteraction query = hitTriggers ? QueryTriggerInteraction.Collide : QueryTriggerInteraction.Ignore; int count = Physics.OverlapSphereNonAlloc( center, radius, _buffer, targetMask, query ); for (int i = 0; i < count; i++) { Collider col = _buffer[i]; if (col == null || !col.gameObject.activeInHierarchy) continue; _targets.Add(col); } OnWaveFired?.Invoke(center); ApplyEffect(center); } private void ApplyEffect(Vector3 center) { for (int i = 0; i < _targets.Count; i++) { Collider col = _targets[i]; Vector3 targetPos = col.transform.position; Vector3 dir = targetPos - center; float distance = dir.magnitude; if (distance > radius) continue; float normalizedDist = distance / radius; float factor = falloffCurve.Evaluate(normalizedDist); if (obstacleCheck && IsBlocked(center, targetPos)) { continue; } float damage = baseDamage * factor; OnWaveHit?.Invoke(col, factor); ApplyPhysicsForce(col, center, factor); } } private bool IsBlocked(Vector3 center, Vector3 targetPos) { Vector3 origin = center + Vector3.up * 0.15f; Vector3 dest = targetPos + Vector3.up * 0.5f; Vector3 direction = dest - origin; float distance = direction.magnitude; int hitCount = Physics.RaycastNonAlloc( origin, direction.normalized, _obstacleHit, distance, obstacleMask ); return hitCount > 0; } private void ApplyPhysicsForce(Collider col, Vector3 center, float factor) { Rigidbody rb = col.attachedRigidbody; if (rb == null) return; Vector3 dir = col.transform.position - center; if (dir.sqrMagnitude < 0.001f) { dir = Vector3.up; } dir.Normalize(); Vector3 force = dir * (pushForce * factor); force.y += upwardModifier * pushForce * factor; rb.AddForce(force, forceMode); } }这套类的设计思路是这样的:检测跟表现完全分离。Fire 方法只负责执行一次环形扫描,把命中的 Collider 和衰减倍数通过事件抛出去。爆炸特效、伤害飘字、音效、相机震动全部挂在外部监听 OnWaveHit 的模块里,冲击波类本身完全不关心这些,好扩展也不容易互相污染。
3.2 衰减曲线的选择:别写死公式,用动画曲线
伤害衰减我用的不是固定公式,而是 AnimationCurve。默认初始化的 Linear 曲线是 0 到 1 线性衰减,好处是直观。但实际调试的时候你会发现,线性衰减在近处太凶、远处太弱,手感并不好。比如技能想做成“近处直接秒杀,中距离重创,边缘擦伤”,线性曲线就很难调。
这时候 AnimationCurve 的优势就出来了,直接在 Inspector 面板里拉曲线,不用重新编译。我把曲线也放到 FalloffCurve 字段里,让策划自己调。你甚至可以调成凹曲线,让边缘伤害比例更高,模拟那种“波压到边缘突然炸开”的特殊手感。
技巧:想让曲线在不同距离的效果可视化,可以在 OnDrawGizmosSelected 里用 Gizmos.DrawWireSphere 画一下范围,再配合 Debug.Log 打印每个目标的衰减倍数,调试效率会高很多。
3.3 物理推力:AddExplosionForce 和自己算方向,我选后者
Unity 自带一个 AddExplosionForce,专门做爆炸力:
rb.AddExplosionForce(pushForce, center, radius, upwardModifier, ForceMode.Impulse);它内部已经做了距离衰减,也会自动把物体沿着“从爆炸中心指向物体”的方向推出去。乍一看很省事,但我用下来发现两个问题。
第一,它自带一个向上的额外分量,upwardModifier 默认就是 0,但实际设置稍大一点,物体就会飞得老高。比如你要做一个贴地冲击波,希望敌人被横向推飞,而不是抛到天上,AddExplosionForce 得把 upwardModifier 调到负数才行,但负数又会导致物体往地下钻,手感很怪。
第二,它和我们的 AnimationCurve 衰减是两套衰减逻辑。如果我在 ApplyEffect 里已经用 factor 衰减了一次伤害,再传给 AddExplosionForce 一个已经衰减过的力,它内部又会按距离衰减一次,最后结果是双重衰减,同一个技能在不同距离的力度衰减得特别快,远处物体根本没反应。
所以最终我选择了自己算方向加力的方案。代码里就是先求方向向量,归一化,再乘以 pushForce * factor,并且把向上分量单独用 upwardModifier 控制。这个方案按说应该会有更多的物理计算量,但实际测试下来并没有额外效率问题,因为是对每个目标执行一次 AddForce,跟 AddExplosionForce 的调用成本基本一致。优点是全部参数都看得见、调得动,不会出现“不明原因的双重衰减”。
3.4 遮挡判定:避免隔墙炸到人
冲击波能不能穿墙,是设计上要拍板的一件事。我在工具类里预留了一个 obstacleCheck 开关,打开后每个目标都要过一遍遮挡检测:从爆炸中心向目标位置发一根射线,射线被障碍层挡住就视作“被墙隔开”,跳过该目标。
这里有个小细节:射线起点和终点都不要直接用爆炸中心和物体中心。爆炸点可能埋在地面下或者贴着墙面,射线起点如果不做偏移,一发射出去就撞到地面,导致所有目标都被判定为“被遮挡”。实战里我给起点加了 0.15 的上抬,目标点加了 0.5 的上抬,同时终点取的是物体位置不是 Collider 的 bounds.center,这样射线在水平方向上更接近真实视线,竖直方向上不会因为地面问题误杀。
不过要提醒一点:这个遮挡检测用的是逐目标射线,目标数量多的时候会增加额外开销。如果一次爆炸命中了几十个目标,每发 3 根射线,几十根射线发出去了,性能消耗需要留意。如果场景里遮挡物不多、或者本身就想要“穿墙波”的设计,这个开关可以直接关掉。
4. 让冲击波“看起来像冲击波”:表现层的完整拼图
物理检测和受力做完了,冲击波已经具备“把物体震开”的能力。但玩家感知上还缺很多东西,这一块我做表现层时走了不少弯路,也总结出几个必须做、做了立刻见效的项。
4.1 事件回调是实现表现层解耦的关键
工具类里预留的 OnWaveHit 事件,作用在这一步彻底体现出来。冲击波只管命中谁、衰减多少倍。至于这个敌人该掉多少血、该弹出什么伤害数字、该播放什么受击动画、脚下该出现什么特效,由外部系统监听事件自己去处理。
举个例子,我在技能系统里加了一个监听:
wave.OnWaveHit += OnImpactTarget; private void OnImpactTarget(Collider target, float factor) { Damageable dmg = target.GetComponent<Damageable>(); if (dmg != null) { dmg.TakeDamage(Mathf.RoundToInt(baseDamage * factor)); } VFXManager.Instance.PlayHitEffect( target.transform.position, factor ); }这样技能释放者不需要知道目标到底是个有血条的怪物、还是场景里的可破坏物,监听方根据自身需求各取所需。做新表现的时候,我只需要再挂一个监听脚本,不需要动冲击波这个类。
4.2 视觉波纹:比想象中简单得多
冲击波扩散光圈的视觉实现,最省事的方案是用一个 Plane 或者 Quad 加一个 Shader 做同心圆扩散。你在 Shader 里传一个 _WaveOffset 参数,用 fract 和 smoothstep 控制一个圆环从内向外扩散。
另一个更常用的方案是用粒子系统:一个 Circle 形状的粒子发射器,让粒子在发射瞬间向外扩张,配合一个快速淡出的特效材质。实测下来粒子方案的性价比最高,引擎自带工具就能做,不需要额外写 Shader。
4.3 相机震动:几行代码就能提升打击感
冲击波释放的瞬间,相机哪怕只震 0.15 秒,手感上限都会明显提升。我自己写了段极简的震屏协程:
public IEnumerator ShakeCamera(float intensity, float duration) { Vector3 origin = transform.localPosition; float elapsed = 0f; while (elapsed < duration) { float t = elapsed / duration; float noise = intensity * (1f - t); transform.localPosition = origin + Random.insideUnitSphere * noise; elapsed += Time.deltaTime; yield return null; } transform.localPosition = origin; }这只是一段极简版本。正式项目里震动曲线建议做成 AnimationCurve,前 20% 的帧振幅拉大,后面快速回落,视觉上比匀速衰减更有冲击力。
4.4 音效和慢动作:压垮骆驼的最后两根稻草
Low 频鼓点音、远处延迟传来的碎裂声、爆炸瞬间短暂的时间缩放(Time.timeScale 调到 0.1 持续 0.05 秒再拉回来)——这几个手段叠加起来,冲击波的整体表现就算立住了。时间缩放可以借助 DOTween 或者协程实现,注意在联机环境或者 UI 动画里尽量别碰全局时间,否则会连同其他系统的动画一起被拖慢。
5. 常见问题与排障实录:这些坑我都替你踩过了
5.1 冲击波放出去,敌人一点反应都没有
这是最高频的问题。排查步骤按顺序走:
第一个查 LayerMask。反射到代码里检查传进去掩码是否包含目标所在的层,最简单的方法是点击场景里的目标物体,看 Inspector 右上角的 Layer 是什么,再用 LayerMask.NameToLayer 打印确认。
第二个查 QueryTriggerInteraction。如果目标身上的 Collider 是 Is Trigger,默认情况下 OverlapSphere 是检测不到的,需要显式传 QueryTriggerInteraction.Collide。我建议直接在工具类里把 hitTriggers 开关打开,然后逐个排除。
第三个查 Collider 和 Rigidbody 是否正常。有 Collider 没 Rigidbody 的物体,物理查询能查到,但不会受到力;有 Rigidbody 但 Collider 是静态的也不会动。冲击波要炸飞东西,目标身上必须有 Rigidbody。
5.2 检测到了目标但伤害没生效
伤害不生效通常是回调链路的问题。确认外部监听是否挂在正确的实例上,确认 OnWaveHit 事件是否在 Awake 里完成订阅。另一个隐形坑是:OverlapSphere 命中的 Collider 可能引用已经销毁的物体,如果没有判空就直接访问 transform,会在控制台刷一堆 MissingReferenceException。我在 Fire 方法里做了 activeInHierarchy 判断,但仍然建议在监听回调里也做一次判空。
5.3 爆炸把场景炸得到处都是碎片,物理完全失控
如果想让冲击波“破坏”一个稳定的场景,但又不希望物体被推得太离谱,可以在被影响物体上加一个最大速度限制。Rigidbody 有一个 maxAngularVelocity 属性,线性速度限制则要手动控制。简单做法是在 FixedUpdate 里判断 rb.velocity.magnitude,超过阈值就做规格化缩放到阈值。另一个思路是给受击物体增加线性阻尼(drag),让冲击波带来的初速度快速衰减,物体飞出去很快就停下来。
5.4 隔墙依然炸到人,遮挡检测形同虚设
这个问题的根源大概率出在射线起点和终点的偏移量上。爆炸点紧贴墙面时,起点在墙的碰撞体内部,Raycast 会直接命中自己脚下的地面或墙体。把起点和终点都适当抬高,并让终点略高于目标位置,能有效减少这类误判。如果还不行,就在遮挡检测结果出来时打印一根 Debug.DrawLine,可视化看一眼射线到底有没有穿过墙。
5.5 OverlapSphere 的检测结果好像滞后了一帧
物理引擎的碰撞状态是在 FixedUpdate 步进中更新的。如果你把 OverlapSphere 放在 Update 里调用,读取到的碰撞体位置信息可能是上一物理帧的旧数据。对于一次性爆炸技能影响不大,但如果要做持续性伤害区域、或者需要精确追踪快速移动的目标,建议把检测放到 FixedUpdate 里,或者在调用前主动 Sync 物理结果。我实测下来,把连续检测逻辑切到 FixedUpdate 后,目标漏检率明显下降。
5.6 性能问题:爆炸一多就掉帧
常见原因有三个方向。一是用了基础版 OverlapSphere 导致 GC 分配频繁,换成 NonAlloc 并预热缓冲区就解决大半。二是缓冲区数组开得太小,命中对象被丢弃后会表现为表现缺失,反复排查会浪费大量时间,建议开大后再压测。三是遮挡检测射线太多,如果命中目标特别多,射线数量和物理查询次数会指数级上升,这种情况建议缓存遮挡检测结果、或者对目标数量做上限限制。
下面把高频问题和排查方式整理成速查表,方便现场救火时直接对着看:
| 症状 | 最可能的原因 | 解决方式 |
|---|---|---|
| 目标完全无反应 | LayerMask 配置错误、Trigger 未命中 | 用 LayerMask.GetMask 或 Inspector 拖选,显式指定 QueryTriggerInteraction |
| 检测到却没有力 | 缺少 Rigidbody、或 Collider 为静态 | 给目标加 Rigidbody,检查 Collider 类型 |
| 有伤害但没被推飞 | 力臂衰减计算重复 | 不要同时使用 AddExplosionForce 和手动衰减 |
| 场景物体满天飞 | upwardModifier 设置过火 | 调小或设负值,给 Rigidbody 增加 drag |
| 爆炸后掉帧 | 使用基础 OverlapSphere、GC 分配 | 换 NonAlloc 版本,预热缓冲区 |
| 结果数量不对,像“吞”了目标 | 缓冲区数组长度不够 | 加大缓冲区到 128/256 |
| 隔墙伤人的 Bug 复现不了 | 射线起点偏移不合理 | 抬高起点和目标点,打印 Debug.DrawLine 观察 |
6. 进阶玩法:从“能用”到“好玩”的几种扩展
基础冲击波做完了,想让它更进一档,有几个方向都是 OverlapSphere 顺手就能扩展出来的。
6.1 多段波纹扩散
如果是想表现“波从中心逐渐向外推”的感觉,可以在短时间内连续多次调用 Fire,每次把 radius 从小到大递增。比如在 0.3 秒内依次调用 radius = 4、8、12 三发,中间间隔 0.1 秒。这样做出来的效果是冲击波像一圈圈声呐波纹一样推出去,伴随粒子表现会很带感。代价是多次物理查询,但间隔开来的调用频率完全可以接受。
6.2 可破坏物链式反应
当 OverlapSphere 命中一个带“可破坏”标签的物体时,那个物体触发自身爆炸,再以它为圆心调用一次新的 Fire。这样的链式扩散会让场景像多米诺骨牌一样连环炸开。实现上要注意对同一物体去重,别让 A 引爆 B 后 B 又引爆 A 死循环,加一个 HashSet 记录已引爆对象就能解决。
6.3 布娃娃物理的反馈
角色被冲击波命中时,如果是布娃娃系统,可以让每个骨骼的 Rigidbody 都施加爆炸力。做法是先用 OverlapSphere 找到角色的根碰撞体,再一次性获取它的 Ragdoll 子物体列表并逐个加力。这个比直接作用于根节点要自然得多,角色会以更真实的姿态被掀翻飞起。
6.4 大批量目标场景下用 Job System 优化
如果场景里同时存在数百上千个可被冲击波影响的对象,物理查询本身开销并不大,真正瓶颈在于后续逐个施加力时的托管层调用成本。这种场景下可以考虑把命中筛选、距离计算放到 IJobParallelFor 里做,主线程只需要根据作业结果执行最终表现。不过这属于进阶策略,常规项目做到 NonAlloc + 合理缓冲区就已经足够稳定了。
我在实际使用中发现,冲击波这套东西,真正把效果拉开差距的不是检测代码本身,而是衰减曲线和力的作用手感。有一次我把 falloffCurve 调成前段平缓、后段陡降的形状,配合推力的 upwardModifier 设成 0.1,原本平平无奇的爆炸突然有了“厚重感”,远处敌人被震飞、近处敌人被狠狠砸进墙体,手感完全不一样。你要是自己上手调试,建议先别动代码,光调曲线和力度参数就能折腾出很多花样。
最后再分享一个小技巧:做冲击波调试的时候,在 OnDrawGizmosSelected 里把检测半径画出来,把每个目标的衰减倍数用 Debug.Log 打出来,一次释放就能看见“从近到远谁该掉多少血”,比起凭感觉调参要靠谱得多。如果后续还想接技能编辑器的模板配置,把 radius、falloffCurve、pushForce 这几个字段做成可以序列化的配置资产,一套冲击波工具就能服务于好几个技能,复用率直接拉满。