ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Unity类银河城敌人AI优化:骷髅兵状态机与受击反馈实战解析

Unity类银河城敌人AI优化:骷髅兵状态机与受击反馈实战解析 “类银河战士恶魔城”这个品类做久了你会发现一个特别明显的分水岭地图设计和关卡搭建决定了一个Demo好不好看而敌人AI和受击反馈才决定它好不好玩。P162这节课的主题是Improve skeleton也就是把那个最不起眼的骷髅兵做出“手感”来。很多跟着教程做到这里的同学都会有同感骷髅不就是来回走、看到玩家就追、攻击一下嘛有什么可提升的但恰恰是这种基础敌人最考验一个开发者的综合能力。我在做自己的Metroidvania项目时花在骷髅兵身上的时间远远超过一个Boss。原因很简单——Boss可以靠演出和数值堆砌但小怪是玩家在地图上反复遭遇的对象任何僵硬、延迟、判定怪异的问题都会在十分钟的试玩里被无限放大。这篇文章把我做完P162后的完整思路整理出来从状态机重构、攻击预警、受击反馈到性能优化全部结合实际代码和踩过的坑希望能帮到正在做同类项目的你。1. 原版骷髅敌人的三个核心问题为什么它看起来像纸片1.1 状态机的短板只会“走、追、打”的三段式先看一个典型的初级版骷髅敌人逻辑大概率长这样public enum SkeletonState { Idle, Patrol, Chase, Attack } void Update() { switch (currentState) { case SkeletonState.Idle: // 等待几秒 break; case SkeletonState.Patrol: // 来回走 break; case SkeletonState.Chase: // 朝玩家移动 break; case SkeletonState.Attack: // 播放攻击动画 造成伤害 break; } }这套逻辑在功能上没错但玩家实际体验是骷髅像一台被遥控的机器动作之间几乎没有过渡攻击判定在动画播放的同一帧就触发了玩家根本来不及反应被打中后也不会有明显的受击停顿。整场战斗变成“互相换血”毫无策略性可言。我自己在早期版本里也是这个状态测试时总觉得哪里不对劲。后来我把玩家视角拉出来录了一段实战视频才发现问题特别明显——骷髅从巡逻切换到攻击的瞬间它的手臂就已经挥出去了但动画里的骨头架子才刚抬起来一半。这种“判定领先于表演”的错位感是我玩过那么多优秀的类银河城作品比如《空洞骑士》《死亡细胞》里几乎不会出现的。因为它们遵循一条铁律敌人攻击前必须有足够明显的预警判定必须严格贴在动画关键帧上。1.2 动画的“广播”问题Animator事件和代码脱节很多Unity初学者在敌人的攻击判定上最容易犯的一个错误就是直接在Update或者攻击状态切入的瞬间用OnTriggerEnter2D去检测玩家。这样做的结果就是动画播放到第三帧才抬手但伤害在动画开始的第一帧就已经发出了。玩家看到自己在空气里被打中自然会产生“这游戏判定有问题”的印象。P162这节课演练的一个重要环节就是把攻击判定的时机从“进入攻击状态的时刻”挪到“动画播放到关键帧的时刻”。Unity的Animation Event就是为此设计的在骷髅的攻击动画里选中“武器挥到最远点”的那一帧添加一个Animation Event在事件回调里执行伤害判定代码。很多同学第一次用Animation Event会困惑为什么我添加了事件函数却一直没被调用这里有个很常见的坑——事件回调的函数名必须是挂在Animator所在GameObject的脚本里的公开方法而且Animator的Culling Mode不能是Cull Completely。一旦模型离开摄像机视野被cull掉动画事件就彻底不触发了。这是一个特别隐蔽的问题后来我在做Boss战的时候也遇到过当时足足排查了一个晚上才发现是Culling Mode的锅。1.3 受击表现近乎为零没有闪白、没有击退、没有顿帧如果让一个普通玩家评价“打击感”他大概率说不出具体指标但他能直观地感觉到“打起来爽不爽”。这种爽感的基础构成就是受击表现的三板斧反馈手段作用实现方式闪白让玩家确认“我打中了”把SpriteRenderer的Material替换成一个能控制_FlashAmount的材质或者用协程改变颜色击退让玩家感觉“有力量交互”在受击瞬间给敌人一个朝向攻击来源方向的AddForce或位移顿帧让打击有一种“切进去”的卡肉感在受击瞬间暂停所有动画一小段时间通常0.05~0.1秒原版骷髅被打中后只会掉血条身体连晃都不晃一下。这样导致玩家就算打中敌人也缺乏正反馈整个过程变成枯燥的数字交换。P162里Improve skeleton的核心就是把这三板斧全部加到骷髅身上让每一次攻击都有了“实质性”的回应。2. 重构骷髅AI状态机让敌人真正“活”起来2.1 为什么非要状态机模式很多初学者会觉得我用if和switch不也能实现敌人AI吗确实能做但当敌人行为越来越复杂时散落的if会让代码越来越乱。我见过一个项目敌人的追击逻辑里塞了七八个bool标记改一个行为就要满世界找哪里引用了这个变量。状态机模式的核心价值在于每个状态只关心自己该做什么状态之间的切换被集中管理未来加新敌人、新行为时扩展成本极低。P162这节课用的就是Unity官方推荐的FSMFinite-State Machine思路在Skeleton这个类里定义了六个状态public enum SkeletonState { Idle, // 待机 Patrol, // 巡逻 Chase, // 追击 Attack, // 攻击 Hit, // 受击 Death // 死亡 }每个状态里只负责自己的逻辑比如Chase里就做两件事朝玩家方向移动 检测是否进入攻击范围。切到Attack后就不再管移动了专心处理攻击前摇和动画事件。这种“单一职责”的设计让整个AI的控制流变得非常干净。2.2 状态切换的条件设计别让敌人当“无头苍蝇”比状态数量更重要的是状态切换的条件。我见过不少朋友把状态机写成了“状态跳来跳去”原因就是切换条件设置有冲突。比如在Chase状态下又设置了“距离小于攻击范围就切Attack”又设置了“距离大于警戒范围就切Patrol”结果敌人就在两个状态之间疯狂横跳看起来特别滑稽。P162的示例给出了一套比较稳健的优先级逻辑DetectPlayer通过一个圆形OverlapCircle检测玩家是否进入警戒范围进入后从Patrol/Idle切到ChaseAttackRangeCheck在Chase过程中如果距离小于攻击范围就切到AttackPlayerLostCheck在Chase过程中如果玩家超出警戒范围就切回PatrolHitInterrupt在任意非Death状态下被玩家击中就切换到HitDeathCheckHP归零直接切到Death。这套逻辑的关键点在于切换条件之间要有优先级不能让两个条件同时为真却跳到不同的状态。比如在Attack状态时如果同时被玩家击中应该优先响应Hit而不是继续傻傻地把攻击动画播完。这在实际战斗中非常影响手感——如果敌人被打中之后还能继续完成攻击玩家会感觉自己“打在空气上”。2.3 用协程和动画事件配合替代“拍脑袋式”的定时器另一个实战中的核心技巧是不要用Invoke或WaitForSeconds来控制AI行为。原因很简单协程或延迟调用一旦被中断如果没有妥善处理很容易造成状态错乱。举个例子你想让骷髅在攻击前有0.5秒的举刀预警于是写了yield return new WaitForSeconds(0.5f)然后执行攻击。但如果在这0.5秒内玩家已经把骷髅打死了协程却还在运行攻击逻辑照常触发就会产生一个“尸体砍人”的Bug。P162改进版的处理方式是攻击前摇的时间长短不是用代码里的协程控制的而是用动画本身的时间长度控制的。动画自然播到出刀帧时通过Animation Event触发判定动画播完结束时通过OnStateExit或者动画末尾的事件切回Patrol。这样一来无论前摇多长只要角色死亡Animator切换到Death状态就能保证攻击逻辑不再执行从根本上杜绝了状态错乱的隐患。我自己后来在做Boss二阶段时也沿用了这个思路把Boss的每个阶段都做成了一个独立动画Clip关键事件全部挂在动画帧上整套代码清爽了不少。3. 攻击预警与动画事件把“公平性”刻进敌人攻击里3.1 为什么说“公平的攻击”比“炫酷的攻击”更重要Metroidvania游戏的核心是探索与战斗而战斗的前提是“公平”。玩家被打败应该是他读错了敌人的出招、没有及时躲避而不是因为敌人的攻击判定突然提前了0.3秒、或者判定框比动画大了两倍。那些经典的类银河城作品在这方面都做到了极致的精细。P162这节课Improve skeleton的第一步就是给骷髅的所有攻击动作加上肉眼可辨的前摇。原版骷髅的攻击几乎是瞬发——玩家刚看到骷髅靠近下一帧就掉血了改进版里骷髅在攻击前会有一个明显的举刀动作同时伴随一个短暂的蓄力停顿。这个停顿既是给玩家的“读招窗口”也是给整个战斗节奏的“呼吸空间”。我个人的设计准则是普通小怪的攻击前摇保持在0.4~0.6秒之间Boss级敌人可以放宽到0.8~1.2秒具体数值取决于玩家角色的移动速度和攻击范围。如果玩家跑得很快前摇短一点问题不大如果玩家是一个偏笨重的角色前摇就必须足够长让他有时间拉开距离。3.2 动画事件挂载细节一个参数的坑在Unity里添加Animation Event的操作并不复杂但有些细节一定要留意。以下是我在P162实操过程中梳理出来的完整步骤第一步在Animator窗口里找到骷髅的攻击Clip双击打开Animation窗口把时间轴拖到“刀挥到最前方”的那一帧。第二步点击时间轴上的“添加事件”按钮在Inspector里填入回调函数名AttackHit()不需要带参数。第三步在Skeleton脚本中定义一个public void AttackHit()方法在这个方法里执行伤害判定。这里有一个特别容易踩的坑Animation Event的回调函数虽然可以写在任意脚本里但Unity只会在挂载了Animator的那个GameObject上的组件里寻找该方法。如果你的攻击判定逻辑写在了子物体上的EnemyWeapon脚本里Animation Event是调用不到的。我建议把AttackHit()统一写在敌人主控脚本上然后在方法里通过GetComponentInChildrenEnemyWeapon()去触发武器判定这样结构最清晰。另外提醒一句Animation Event只在Clip播放期间有效如果你的攻击动画会被Hit或Death状态打断那么后续的关键帧事件就不会触发了。这其实是好事因为它天然防止了“死掉的敌人还在造成伤害”的Bug。3.3 攻击判定的具体写法不要挂在触发器上很多初学者习惯把攻击判定直接写在一个子物件的OnTriggerEnter2D里这在大体上没问题但会出现一个“判定重复触发”的隐患如果玩家在攻击帧停留了两帧触发器接口就会连续触发两次造成一次攻击掉两次血。我的做法P162里也是这个思路是引入一个bool hasDealtDamage标记public void AttackHit() { Collider2D[] hits Physics2D.OverlapCircleAll(attackPoint.position, attackRange, playerLayer); foreach (Collider2D hit in hits) { if (!hasDealtDamage) { // 造成伤害 触发玩家的受击反馈 hit.GetComponentPlayerController().TakeDamage(attackDamage); hasDealtDamage true; } } }然后在攻击动画播放完或切出Attack状态时把hasDealtDamage重置为false。这样每次攻击只产生一次伤害无论敌人和玩家之间的碰撞检测触发了多少次。3.4 特效与音效的同步视觉和听觉必须“同时发生”P162这节课Improve skeleton里还有一个小细节差点被很多人忽略——攻击特效和音效的播放时机。骷髅在挥刀的一瞬间既要播放挥击特效也要播放砍空气的音效或者击中的音效。这个“同一瞬间”很重要因为人脑对视觉和听觉的同步性极其敏感差上0.1秒就会觉得“手感不对”。我通常的做法是在同一个Animation Event帧上再添加一个名为PlayAttackSFX()的事件方法。同一帧触发多个事件Unity的Animation Event在同一帧上是支持多个事件同时存在的。这样视觉、判定、听觉三者在同一帧内被触发打击感就出来了。4. 受击反馈的完整链路闪白、击退、顿帧与掉落表现4.1 闪白最廉价却最有效的“命中确认”闪白可以说是性价比最高的受击反馈手段。玩家打中敌人敌人瞬间变白再恢复正常这一下“视觉冲击”直接告诉玩家你打中了。闪白的实现方式有很多种最省事的方案是改SpriteRenderer的color属性public IEnumerator HitFlash() { spriteRenderer.color Color.white; yield return new WaitForSeconds(0.1f); spriteRenderer.color originalColor; }但如果你想让效果更高级一点可以给骷髅的材质加一个_FlashAmount属性通过Shader控制闪白程度闪白效果会自然得多。P162的示例用的是纯色修改因为教程阶段够用但我还是强烈建议你在项目里尝试Shader方案——尤其是当敌人数量变多、闪白频率变高时纯color的方式会让整个画面看起来“乱糟糟的”而Shader方式可以精准控制只有受击的那一个敌人变白。4.2 击退让每一次打击都有“推动力”击退效果直接影响战斗的手感。没有击退玩家打敌人就像打一堵墙毫无反馈击退力度太大又会让敌人无限被击飞降低战斗难度。我一般给出的标准是普通攻击击退距离控制在1~2个格子以内重击可以略大但不要超过4格。实现击退的代码一般写在受击方法里public void TakeDamage(int damage, Vector2 knockbackDir, float knockbackForce) { hp - damage; if (hp 0) { // 切换受击状态 ChangeState(SkeletonState.Hit); rb.velocity Vector2.zero; rb.AddForce(knockbackDir * knockbackForce, ForceMode2D.Impulse); StartCoroutine(HitFlash()); } else { ChangeState(SkeletonState.Death); } }有一个细节值得注意击退的力不应该直接加在速度上而是先清零当前速度再AddForce否则敌人会在移动状态下被打飞产生一种“加速弹出去”的不自然效果。这也是我在实际测试中反复对比后总结出的结论。4.3 顿帧最容易被忽略的“卡肉感”顿帧Hit Stop是指在攻击命中的一瞬间整个游戏画面暂停几帧通常是50~100毫秒然后再恢复运动。这个技巧在很多动作游戏里被称为“卡肉”它让玩家的打击感变得非常“扎实”。Unity里实现顿帧的基本方式是public static IEnumerator HitStop(float duration) { Time.timeScale 0f; yield return new WaitForSecondsRealtime(duration); Time.timeScale 1f; }注意这里用的是WaitForSecondsRealtime因为timeScale 0时普通WaitForSeconds是无效的。还有一个更进阶的做法是不用全局timeScale而是只暂停当前敌人的Animator和相关物理组件这样就算顿帧也不会影响玩家操作。但P162这节课为了简化效果展示用的是全局方式实际项目中我建议做成一个单例HitStopManager集中管理是否要暂停全局、暂停多久。4.4 死亡表现别让尸体“原地蒸发”骷髅死亡时最忌讳的就是直接Destroy(gameObject)一下子消失得无影无踪——这在视觉上非常廉价。P162里改进后的做法是切换Death状态让骷髅播放一段倒地/散架的死亡动画在动画末尾的Event里才调用Destroy(gameObject)同时可以配合骨骼碎块四散的特效、掉落物生成等表现。这里有一个很容易踩的坑Death状态切换后一定要禁用它身上的Collider2D否则尸体还是会作为障碍物阻挡玩家或者在玩家接触时触发伤害。我习惯在ChangeState(SkeletonState.Death)里统一执行collider.enabled false、rb.velocity Vector2.zero、rb.bodyType RigidbodyType2D.Static这三个操作确保尸体不会干扰场景物理。5. 性能与扩展性一个骷髅的框架如何复用给全图敌人5.1 用对象池管理敌人别让频繁生成的骷髅卡死GC类银河战士恶魔城的地图通常很大敌人会被频繁地杀死和重生。如果每次死亡都Destroy、每次重生都Instantiate内存分配和GC压力会非常明显。我建议从P162这个阶段就开始引入对象池。对象池的核心逻辑不复杂public class SkeletonPool : MonoBehaviour { public static SkeletonPool Instance; [SerializeField] private Skeleton skeletonPrefab; private QueueSkeleton pool new QueueSkeleton(); public Skeleton Get() { if (pool.Count 0) { Skeleton sk pool.Dequeue(); sk.gameObject.SetActive(true); return sk; } return Instantiate(skeletonPrefab); } public void Return(Skeleton sk) { sk.gameObject.SetActive(false); pool.Enqueue(sk); } }然后在敌人死亡动画的最后一帧把骷髅归还给对象池而不是Destroy。这样做的好处是敌人重复生成时不再需要重新加载也不会产生垃圾内存整个地图的内存占用会稳定很多。5.2 数据驱动把敌人的属性抽成ScriptableObject一旦你有了多种敌人你一定会希望每个敌人之间的差异不只是换个美术资源而已。P162这节课Improve skeleton虽然只涉及骷髅兵但从架构设计的角度出发我非常推荐把敌人的基础属性抽成一个ScriptableObject[CreateAssetMenu(fileName EnemyData, menuName Game/EnemyData)] public class EnemyData : ScriptableObject { public string enemyName; public int maxHp; public int attackDamage; public float patrolSpeed; public float chaseSpeed; public float detectRadius; public float attackRange; public float attackCooldown; }每个具体的敌人预制体上只需要挂一个Enemy基类脚本然后在Awake时把EnemyData里的数值填充到自己的字段即可。这样一来你想调一个骷髅兵的数值根本不用打开Prefab直接编辑对应的EnemyData资产就行批量管理非常方便。5.3 让骷髅BOSS化同一个AI模板如何扩展出精英怪P162做完之后骨架已经相当完整了接下来的扩展空间其实非常大。你可以基于这套Skeleton的AI框架做出一只“骷髅王”精英怪只需要做三件事把骷髅的模型和动画替换为一个更大的Boss模型在EnemyData里提升血量、攻击力、移动速度在状态机里增加一个“第二形态”——比如血量低于50%后进入狂暴状态攻击速度翻倍并增加一个范围剑气攻击。我已经在自己项目里用这套框架复用了至少五种敌人包括骷髅兵、蝙蝠、哥布林、石像鬼和一个精英Boss基本不需要重写AI逻辑只需要在新敌人的AttackHit()里替换不同的攻击判定方式其他地方全都复用。这个设计收益在项目初期是看不出来的越到后期越是明显。5.4 实战中遇到的两个坑这里一并说掉第一个坑是Animator的Apply Root Motion。很多新手把Boss的模型拖进场景后会莫名其妙看到敌人自己乱跑就是因为模型自带的Animator Controller里勾选了Apply Root Motion。在2D类银河城游戏里敌人的位移应该完全交给代码控制在Animator组件上把这个选项关掉不然它的动画位移会跟刚体位移叠加产生“漂移”的感觉。第二个坑是多个敌人同时受击时的顿帧叠加。如果你用的是全局timeScale顿帧方式且场上同时有三个敌人被玩家一个范围攻击打中每个敌人的受击逻辑都调用一次HitStop就会出现顿帧时间叠加上涨画面卡住好几秒的情况。解决方式是在HitStopManager里做一个“如果已经在顿帧中则忽略新的顿帧请求”或者用一个时间戳判断只采用最近一次顿帧的结束时间。做敌人效果提升这件事说难不难说简单也真不简单。它考验的其实不是某个单独的技术而是对“玩家体验”的理解——你做的每个反馈都应该让玩家觉得“我这一下打得很爽”或者“这个敌人很有威胁我得认真对待”。P162的Improve skeleton只是一个起点把这一套状态机、动画事件、受击反馈的组合拳吃透后面做任何敌人都会顺手很多。最后分享一个小习惯每当我给敌人加完一个新的反馈效果我都会自己进游戏里反复打上个十来回合不看代码纯粹用玩家视角感受。哪里有卡顿、哪里有延迟、哪里判定不对劲一打就全暴露了。好的手感不是写出来的是调出来的这句话真的一点不假。
返回列表