尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Unity近战武器包实战:动画事件与打击感打造丧尸战斗系统

Unity近战武器包实战:动画事件与打击感打造丧尸战斗系统 我做这套Unity近战武器包的时候思路其实很简单棒球棍、斧子、撬棍三种手感完全不一样的近战武器扔到一个丧尸潮场景里让玩家切着武器跟丧尸对刚。这个项目看起来是“武器包”但真正做起来你会发现它本质上是一整套近战战斗系统的样板工程——动画状态机怎么组织、攻击判定怎么做才不飘、打击反馈怎么调才够爽、丧尸AI怎么跟着你的节奏被打断硬直这些全串在一起才算一个能玩的武器包。这篇文章我打算把整个项目从武器定位、系统拆解、关键代码到调试踩坑完整过一遍。适合三类人看一是想快速搭出丧尸生存游戏原型的开发者二是想研究Unity近战打击感尤其是动画事件和碰撞判定的动作游戏爱好者三是买了类似武器包素材却不知道怎么整合进自己项目的朋友。文章里所有方案都是我从实际项目里沉淀下来的不少是文档里写不到的东西照着做你也能搭出一套能砍、能砸、能敲的近战丧尸战斗Demo。1. 武器包的核心定位与整体设计1.1 三种武器的差异化手感设计先说武器包最容易被忽略、但恰恰是灵魂的一点三把武器必须玩起来完全不一样。如果棒球棍、斧子、撬棍只是换了模型、改了伤害数字那这个武器包就没有存在价值。所以我在设计之初就给每把武器定了完全不同的“性格”。棒球棍的定位是均衡之王。它的攻击动画有一个很明显的起手蓄力动作玩家会看到角色把棍子举到后侧方顿一下然后猛地横向挥出。这个“顿一下”是为了让玩家在心理上感受到“这一棒有分量”同时正好覆盖了命中判定的时间窗。实际操作中我习惯把挥击的命中帧放在动画前段偏后一点的位置大概是整段挥击动画的30%到40%处——因为挥到那里时武器尖端线速度最大视觉上也是离身体最远、最该造成伤害的瞬间。棒球棍的伤害中等、攻速中等但击退力强一棒子能把丧尸抡得后退好几步非常适合新手玩家建立“我先把你打退再补刀”的节奏感。斧子的定位是重击爆发。它的攻击动画是自上而下的劈砍起手慢、收招更慢但伤害是棒球棍的近两倍而且对丧尸有概率触发“斩首”级别的硬直——其实我在代码里做的是五分钟内三次命中进入可处决状态配合一个处决动画打击感直接拉满。斧子的手感关键在于出手后的沉重感所以它的动画播放速度我调成了0.75倍并且在命中帧加了额外的血液粒子生成量。撬棍的定位则是速攻压制。棍体细、重量轻攻击方式是直刺加短挥几乎没有什么起手动作动画播放速度是1.25倍伤害低但攻击间隔极短命中后还能给丧尸叠加一个短暂的“破防”状态让丧尸的攻击前摇变长。这三把武器放在一起玩家面对不同数量的丧尸就会自己思考单挑用斧子砸被包围用棒球棍清场残血收割或连招用撬棍捅。这就是武器包的核心价值——用数值和动画节奏逼出玩家的策略选择。1.2 战斗系统需要拆解的模块清单动手写代码前我把整个武器包拆成了九个模块每个模块只负责一件事避免脚本之间互相拉网线。这个习惯帮我在后续调试时省了大量时间。第一个模块是PlayerController负责移动、转身、跳跃、冲刺以及向武器系统发出“攻击”指令的入口。第二个模块是WeaponData用ScriptableObject承载武器的所有数据包括伤害、攻速、击退力、动画速度倍率、特效引用、音效引用。第三个模块是AnimatorController管理角色所有动画状态尤其要保证攻击状态之间的切换不出错。第四个模块是攻击判定也就是武器上的Trigger碰撞体配合动画事件做伤害检测。第五个模块是HitReact管理丧尸被命中后的受击、硬直、处决、死亡状态。第六个模块是音效系统负责挥动音效、命中音效、怪物叫声音效的分层播放。第七个模块是特效系统管理命中特效、武器拖尾、血液粒子的生成与回收。第八个模块是敌人生成器负责场景中丧尸的波次生成和对象池管理。第九个模块是UI层包含武器切换提示和简单的技能指示器。这里最需要注意的边界是攻击判定模块只负责“判定”和“结算伤害”不负责播放特效和音效。特效、音效通过事件向外广播由专门的系统响应。我早期写项目喜欢在攻击脚本里直接实例化粒子、播放音频结果到了后期每把武器加特效都要改攻击代码牵一发动全身。事件驱动之后攻击脚本只喊一声“hitted”其他系统自己干活干净得很。2. 近战武器系统的关键技术拆解2.1 动画状态机让每把武器有自己的节奏Unity的Animator Controller是整个打击感的地基很多人打击感差问题不是出在特效代码上而是动画状态机的过渡设置压根没做对。近战武器包我采用的是单Base Layer加OnTop的武器覆盖层方案。Base Layer里只放Idle、Run、Attack_A、Attack_B、HitReact、Death这些全身动作武器层单独处理每把武器的持握姿势和细节动作。攻击状态的设计有几个容易踩坑的点。第一攻击动画绝不能开启“Has Exit Time”。这个选项一旦勾上动画必须播到一定长度才能切换出去导致玩家疯狂点击鼠标也无法连续出刀手感就像卡了壳一样。正确做法是关闭Has Exit Time把攻击间的过渡时间设成0.05秒到0.1秒这样第二次点击可以随时打断第一次攻击的收招阶段动作衔接凌厉很多。第二攻击状态应该是“自循环”的也就是说Attack状态可以过渡到Attack状态否则你需要给每一段攻击单独建一个状态。但这里有一个细节攻击不能无脑自连否则玩家按住鼠标后角色会像抽搐一样无限出刀。我在攻击脚本里用一个cooldown计时器限制攻击频率只有冷却结束并且玩家再次按下攻击键状态机才允许再次进入Attack。第三动画片段的速度是攻击节奏的核心参数我在WeaponData里存了一个animationSpeedMultiplier棒球棍设成1.0斧子0.75撬棍1.25运行时直接赋给Animator这样调节手感不需要重新导入动画。动画事件是近战武器包的又一个命门。攻击动画播放时我会在三个关键时间点打上事件标记第一帧标记AttackStart武器拖尾开始显示攻击判定碰撞体准备开启挥击轨迹中段标记HitDetect这里才真正开启碰撞检测收招阶段标记AttackEnd碰撞体失效拖尾关闭允许玩家进入下一次攻击。你要特别注意动画事件调用的函数名必须和接收组件的方法名完全一致而且Animator挂在角色根节点上碰撞体组件也在根节点下事件才能按预期触发。我在项目里遇到过很奇怪的问题动画事件偶尔会漏触发后来发现是Animator的Culling Mode设成了CullCompletely摄像机一离开角色视野更新就停了事件自然不跑。把Culling Mode改成Always Animate就解决了一半的问题。2.2 攻击判定碰撞盒、射线还是球形扫描攻击判定是近战武器包最容易被人问“你怎么做得这么准”的部分。我先把三种主流方案对比一下再告诉你我为什么选碰撞盒。一是Camera射线方案。从屏幕中心射一条Raycast命中谁就结算谁。这个方案在第一人称射击游戏里最常见代码也最简单。但用在第三人称近战上有个致命缺陷玩家挥舞武器时准星不一定对着你要砍的目标尤其丧尸围过来的时候你想打左边那只屏幕中心的射线却打到右边那只观感极其割裂。二是在武器模型上挂Trigger碰撞体靠动画事件控制碰撞体激活时机触发OnTriggerEnter检测并结算。这套方案能完美匹配武器挥舞轨迹群攻体验真实是现在动作游戏的主流做法。三是OverlapSphere以玩家前方为中心做一个球形范围检测。这个方案实现最快适合魔法攻击、爆炸AOE这类技能但它没有方向性无法区分“我明明背对着你却被判定打中”这种尴尬场景。我在武器包里选择了武器碰撞盒方案同时把撬棍的破防技能单独用OverlapSphere做二次判定相当于两种方案都在用。具体实现是这样在棒球棍模型上放两个BoxCollider一个覆盖棍身一个覆盖握把前面一点点的区域都勾选Is Trigger。位置和大小要反复调让碰撞体的轮廓尽量贴近棍子的实际打击面。然后写一个AttackHitDetector脚本挂在每个碰撞体对应的子物体上脚本里维护一个HashSet用来记录本次攻击已经命中过的目标InstanceID防止同一只丧尸被同一个挥击动作连续结算三四次伤害。private HashSetint _hitTargets new HashSetint(); private void OnTriggerEnter(Collider other) { if (!_checking) return; if (!other.TryGetComponent(out IDamageable damageable)) return; int instanceId other.gameObject.GetInstanceID(); if (_hitTargets.Contains(instanceId)) return; _hitTargets.Add(instanceId); var dir (other.transform.position - transform.root.position).normalized; damageable.TakeDamage(_weaponData.damage, dir, _weaponData.knockback); }这段代码里有两个细节值得说。第一我用一个bool变量_checking控制是否检测这个变量由动画事件在命中帧置为true在AttackEnd置为false。只在那短短的几帧内检测就能避免攻击前摇阶段把敌人“蹭死”的bug。第二每次攻击开始时调用_hitTargets.Clear()保证新一次挥击可以再次命中同一个敌人——否则玩家只能砍每个丧尸一次那还怎么玩。2.3 打击反馈的三层结构现在到了整个武器包最有技术含量、也最能让玩家“哇”出声的部分打击反馈。我总结了一套三层结构缺一层手感都会干瘪。第一层是命中停顿业界俗称Hit Stop。原理很简单命中瞬间把动画速度降到0停一小段时间再恢复。用游戏玩家听得懂的话解释就是“打中那一枪全世界都顿了一下”。Unity实现方式有两种一种直接动Time.timeScale简单粗暴但会把UI、粒子、相机全部停住风险是协程里如果不用真实时间恢复会造成整个游戏卡死另一种更推荐把丧尸和角色的Animator都设成speed 0然后启动一个协程等待真实时间的80毫秒再恢复动画速度。我用第二种因为只影响战斗相关元素UI还能继续转表现更可控。命中停顿时间可别乱设我试过100毫秒以上手感会非常“肉”80毫秒左右刚刚好斧子重击可以到120毫秒但再多就拖沓了。第二层是镜头震动。命中后给摄像机挂一个持续大约0.1秒、幅度0.03到0.05单位的随机震动。实现用一个小脚本在Update里用Mathf.PerlinNoise生成位移命中时触发震完自动回中。这里有个巨坑摄像机如果直接挂在角色根节点下相机的位移会和角色动画的motion混在一起看起来像画面乱飘。我的做法是相机挂在Player Root下的单独子物体上挂一个FPCameraShake脚本负责独立震动震动恢复后强制回到本地坐标原点你就能解决“震完画面歪了”的问题。第三层是敌人受击反馈。丧尸命中后立刻切换到HitReact动画状态同时进入一个硬直计时硬直时间内不能攻击也不能移动。为了让受击动画不“等”我建议用Animator.CrossFade(HitReact, 0.1f)而不是改bool变量等状态机自己过渡——后者有延迟打击感就泄了。丧尸的HitReact状态也需要支持FromAnyState也就是无论它正在走、正在攻击、还是正要转身被打了都能立刻硬直。这里仍有性能问题同一帧被砍中三只丧尸它们同时CrossFade到HitReact状态机切换开销会突增。为了缓解我限制每波同帧受击数量最多两只剩下的延迟两帧再处理视觉基本看不出差别。这三层反馈的先后顺序是命中帧先做碰撞检测结算伤害然后丧尸立即播放受击动画动画到第1帧时触发镜头震动和命中停顿同一帧生成命中特效和音效。全部加在一起从玩家按下鼠标到丧尸做出反应中间不超过150毫秒人类感知阈值大约100毫秒所以这一整套必须在极短时间内完成才有效果。3. 实战搭建从空项目到一套可玩武器包3.1 基于ScriptableObject的武器数据管理做武器数据管理时我最推崇的材质是ScriptableObject——它天然适合做可扩展的数值配置。项目里先创建一个WeaponData基类字段包括武器名称、伤害值、攻击间隔、挥击范围、击退力、动画速度倍率、命中音效、挥动音效、命中特效预制体、拖尾预制体等等。然后为三把武器分别创建资产文件填上各自的参数。我整理了一份我自己调出来的平衡性数值表实测下来手感比较舒服。伤害数值的基准是普通丧尸100血棒球棍打头伤害翻倍、打身体1倍斧子打所有部位都是1.5倍伤害且有概率秒杀撬棍打身体0.8倍但破防后对胸口弱点有额外加成。下面是基础数值参数棒球棍斧子撬棍伤害254515攻击间隔秒0.650.90.35击退力2.01.00.5硬直时间秒0.40.70.15动画速度倍率1.00.751.25命中停顿毫秒8012050这个表不是死数值你完全可以根据自己游戏的手感偏好调整。我自己的经验是先调到“能玩”然后跑到实际场景里打两分钟丧尸每调一个数值就试玩一次不要迷信网上任何“标准值”。武器切换时主要工作就是把当前武器对象禁用启用目标武器对象并把WeaponData引用替换到攻击脚本上。所有武器都挂在角色的右手骨骼节点下提前为每把武器设置好局部位置和旋转避免切换后武器“飘”在手掌外面。拾取时我直接在武器物体上挂了一个Pickupable脚本玩家靠近时显示“按E拾取”核心是利用Unity的OnTriggerStay检测距离。3.2 丧尸AI与受击反馈丧尸AI我没有用任何行为树插件直接写了一个轻量级状态机因为丧尸的行为模式其实非常固定巡逻、警觉、追逐、攻击、受击、死亡。枚举加Update机已经足够引入复杂框架反而增加维护成本。丧尸的核心组件包括Animator、NavMeshAgent、和一个ZombieController脚本。ZombieController里维护当前状态Update里按状态机逻辑执行。追逐状态下使用NavMeshAgent.SetDestination(player.position)朝玩家移动stoppingDistance设为1.8米小于这个距离就切换到攻击状态。攻击状态有前摇、挥击、后摇三个子阶段前摇时丧尸会抬头吼叫并进入举臂动画这时是玩家最好的反制窗口——撬棍的破防效果本质上就是延长这个前摇时间让玩家有额外0.3秒反应。受击后状态切换到HitReact硬直结束如果玩家还在范围内就回到攻击否则回追逐。受击反馈的核心在IDamageable接口。丧尸、木箱、油桶都实现这个接口攻击脚本通过TryGetComponent拿到接口后统一调用TakeDamage。接口里传入伤害值、攻击方向、击退力三个参数丧尸内部根据击退力计算硬直时间。这一点对武器包的扩展性很重要以后你加一把锤子、一把电锯只要配置好WeaponData不用改任何攻击逻辑新武器就能正常工作。这里我喂大家一个自己踩过的坑千万不要在ZombieController的Update里频繁调用GetComponent去拿Player引用或者武器引用。每帧GetComponent会带来不必要的性能消耗而且会让你在写代码的时候养成偷懒的习惯。正确做法是在Start或Awake阶段缓存所有引用或者用一个全局GameManager在玩家创建时广播引用。我在武器包里用的是一个简单的单例丧尸初始化时直接读取PlayerController.instance省去了每帧查找的消耗。3.3 音效、特效与拾取切换系统先说话音效。近战武器的音效分三路混在一起才能形成“破空、碎裂、惨叫”的层次感。第一路是挥动音效也就是whoosh每次攻击的起手阶段播放棒球棍是低沉的风声斧子是更大的破空声撬棍是短促的嘶声。第二路是命中音效需要分开材质和部位——砸到丧尸头部、砸到肉体、砸到木板、砸到金属都会用不同的AudioClip我在WeaponData里放了hitSoundHuman、hitSoundWood、hitSoundMetal三个槽位命中时根据碰撞体的Tag或材质切换。第三路是丧尸本体叫声平时随机播放低吼受击时播放短促的惨叫死亡时播放一声拉长的哀鸣。三个音频源建议都挂在丧尸身上用2D模式播放不要挂三维空间定位——因为近战战斗距离太近三维音效的方位感不明显反而容易丢失细节。特效部分近战武器标配是命中特效、血液粒子、武器拖尾三件套。命中特效我建议做一个通用粒子池命中时在世界空间生成一个小型爆发包含闪光、碎片和烟雾闪光用Additive材质能增强视觉冲击。血液粒子要克制尤其你不能把场景弄得过于血腥我倾向于做半透明的暗红色粒子快速散开并上浮消失既能明确传达“命中了”又不会踩雷。武器拖尾用Trail Renderer在AttackStart事件启用、AttackEnd事件禁用拖尾时间0.08秒宽度根据武器调节——棒球棍宽一点撬棍窄一点。Trail Renderer的开销不小三把武器同时激活拖尾是个性能隐患所以切换武器时一定要记得把旧武器的Trail组件禁用掉否则上一把武器的残影会一直挂在右上臂很影响观感。拾取与切换的UI我做得很轻量。武器掉落在地上时头顶会有个浮动“按E拾取”的TextMeshPro提示拾取后自动添加到玩家的武器列表中。按数字键1、2、3切换武器。切换时我会做0.2秒的切换动画期间角色无法攻击防止出现“还没拔出来就已经砍出去”的穿帮。新武器切换进来时旧武器的碰撞体、拖尾、WeaponIK目标点全部重置新武器的动画状态Idle在武器层覆盖出来这样玩家拿棒球棍和拿斧子的持握姿势就能区分开。3.4 用对象池支撑多丧尸同屏做丧尸生存类游戏同屏几十只丧尸是基本需求但如果你用Instantiate和Destroy来管理丧尸很快就会卡成幻灯片。原因在于Instantiate会触发序列化拷贝、初始化、Awake等一系列重量级操作频繁使用会持续产生垃圾内存并触发GC。我在项目里写了个简易对象池ZombiePool预先把20只丧尸实例化好存进一个Queue需要时从队列取一只SetActive(true)并重置状态击杀后SetActive(false)放回队列。public class ZombiePool : MonoBehaviour { [SerializeField] private ZombieController _prefab; [SerializeField] private int _initialSize 20; private QueueZombieController _pool new QueueZombieController(); void Start() { for (int i 0; i _initialSize; i) { var zombie Instantiate(_prefab, transform); zombie.gameObject.SetActive(false); _pool.Enqueue(zombie); } } public ZombieController Get() { var zombie _pool.Count 0 ? _pool.Dequeue() : Instantiate(_prefab, transform); zombie.gameObject.SetActive(true); return zombie; } public void Return(ZombieController zombie) { zombie.gameObject.SetActive(false); _pool.Enqueue(zombie); } }列出来快二十行代码但核心逻辑就是“提前造好、重复使用”。配合对象池我还做了出生波次管理开局刷5只击杀完再刷10只第三波15只。每一波的丧尸从场景里的几个固定重生点随机出现用一个小范围的随机偏移避免整整齐齐走出来看着出戏。同屏超过15只时我会把距离玩家最远的几只丧尸的NavMeshAgent先禁用掉等玩家靠近再启用这样能省下不少寻路性能。另外一个有用技巧是给丧尸挂LODGroup距离远的丧尸降低骨骼顶点渲染精度肉眼几乎分辨不出来但帧率能稳住。4. 常见问题与调试实录4.1 动画事件“漏触发”怎么办我做这个项目的第一个礼拜就被动画事件坑了一整天。表现是棒球棍挥出去伤害偶尔触发偶尔不触发时间完全没有规律。排查方向很多人会直接怀疑代码逻辑但真正的凶手往往是动画事件本身。第一事件函数名拼写必须和接收脚本的方法名完全一致包括大小写。Animation窗口里点一个事件需要在下方面板里手动下拉选择方法而不是自己输入这是最稳的方式。第二事件必须挂在正确的动画片段上——如果你的攻击状态实际上播放的是Attack_A但事件打在了Attack_B片段上自然永远不会触发。第三Animator的Culling Mode如果设成CullCompletely只要角色或Camera被遮挡动画更新停止事件就漏掉。这一点我前面提过排查顺序中一定要放在前三。第四检查动画事件的目标层级事件只会发给Animator所在GameObject上挂载的MonoBehaviour组件如果攻击脚本挂在子物体上事件就收不到。我调试的时候最喜欢开一个DebugWindow在OnAttackStart、OnHitDetect、OnAttackEnd里分别打一条日志观察动画事件是否按照预期顺序被触发。日志不齐就直接定位到是哪一环丢了然后逐个检查上述四个原因。这个方法非常蠢但极其有效强烈推荐。4.2 武器穿模与命中位置偏差穿模在所有近战游戏里几乎无法完全避免但你要想尽办法控制在玩家看不出来的范围内。最常见的是武器模型穿过丧尸身体再结算伤害视觉上非常出戏。我的处理思路是尽量让碰撞体更“薄更靠前”让检测到的位置看起来像武器尖端已经够到目标。具体做法在棒球棍模型的尖端和棍身附近放两个细长的BoxCollider而不是包住整个武器模型的大碰撞体。碰撞体到底多大、倾斜多少度是要靠一遍遍预览调整出来的。你在Scene视图里点击武器物体调出碰撞体线框然后手动拖动到攻击动画中段看看它是否覆盖了高概率命中区域。一个技巧是把碰撞检测的触发点尽量贴近网格表面这样即使有一点偏差玩家注意力被特效和音效吸引也能糊弄过去。命中位置偏差的另一个来源是攻击方向的判定方向。我在TakeDamage里用攻击方向向量去计算击退方向如果这个向量取的是武器的世界坐标减玩家坐标可能会因为武器旋转造成方向错乱。修正方法是直接取玩家角色的正前方方向向量作为击退方向这样无论武器怎么甩动击退方向都稳定指向玩家面向。4.3 多武器切换时的状态残留切换武器后出现状态残留是很多武器包的通病。我遇到过的情况包括从斧子切回棒球棍棒球棍还是斧子的0.75倍动画速度撬棍的拖尾在切换后依旧发亮旧武器的碰撞体还开着玩家一转身就把远处的丧尸“隔空”打掉。这些问题根源在于每个武器组件各管一摊切换时没人统一收尾。解药是做一个PlayerWeaponManager脚本它持有所有武器的引用切换时先执行当前武器的DisableWeapon()把所有子物体的碰撞体、拖尾、动画速度、IK权重全部还原再执行新武器的EnableWeapon()。DisableWeapon里一定要把Animator.speed重置为1.0把武器层状态切回默认Idle把当前攻击的冷却计时器清零。按这个流程做完后切换武器就是一次完全干净的状态重置不会再出现上一次攻击的残影或速度泄漏。4.4 性能优化别让特效和射线拖垮帧率我最初把武器包做出来后在编辑器里跑得挺欢一打包到真机就发现砍到第四只丧尸的时候开始掉帧。一度怀疑是模型面数太高但用Profiler查了一遍后发现真正的性能大头居然是特效粒子和对象池外的额外实例化。粒子特效的坑在于每个命中特效都生成一堆子粒子如果玩家的撬棍一秒捅三次每次再爆一堆粒子垃圾回收就会被频繁触发导致帧率骤降。我后来把命中特效全改成了对象池管理特效预制体预先实例化10个命中时从池子里取一个播放播完自动隐藏回池。同一个预制体如果有多个特效同时播放池子容量也要预留够我直接预设了15个并发量实际项目里很少会同时爆出15个特效。另一个坑是对象池里丧尸太多但激活数量不大时少量丧尸会无限播放空闲动画导致CPU占用。解决方式是给远处的丧尸设一个“休眠”模式停止播放动画、禁用Animator激活时再恢复。这个做法配合LODGroup一起能大幅降低同屏消耗。最后我还要提一下OverlapSphere和所有射线检测都应该指定LayerMask我只检测“Enemy”层而不是默认的全层级检测。否则垃圾桶、箱子、门把手都会成为判定目标性能白白损耗。最后分享一点个人体会做近战武器包这件事最大的收获不是代码技巧而是明白了“打击感是一种精密的时间艺术”。命中停顿、动画事件、特效生成、镜头震动每一环都需要精确到几十毫秒级别的时间配合差一点手感就完全不一样。我试过把命中停顿从80毫秒改成150毫秒明明是同一套代码、同一把棒球棍打丧尸的感觉就从“脆”变成“闷”可见这些参数有多么敏感。如果你也在调自己的近战系统我的建议是永远别信网上抄来的“标准参数”打开游戏自己打十只丧尸凭手感决定一切——你的手指比任何编辑器里的数字都更懂什么是爽快。
返回列表