
1. 写在本集之前为什么敌人制作是Demo的“分水岭”做类银河恶魔城Demo前几集基本都在搭角色控制器、摄像机、基础场景这些“地基”活。到了第四集终于开始碰敌人了。这一步在Demo开发里是个明显的分水岭玩家角色再顺滑没有像样的敌人做对手整个游戏就只是“跑步模拟器”。反过来敌人做得好不好直接决定Demo有没有“游戏感”。我这一集的目标很简单做一批能打、能追、会受伤、会死亡的敌人而且是“一批”不是“一个”。很多人做敌人喜欢一个怪写一套代码复制粘贴改一改结果做到第十个怪的时候代码里全是重复逻辑改一个公共行为要翻五个脚本。所以我这集的第一原则是做一个可复用的敌人基类和一套通用的AI状态机然后往里填不同的行为数据。这套思路放到Unity里核心就是三件事一个EnemyBase基类管理血量、受伤、死亡、击退这些通用逻辑一套有限状态机FSM框架让每个敌人都有 Idle、Patrol、Chase、Attack、Hurt、Death 这些状态但每个状态的具体行为可以重写用 ScriptableObject 或者 Inspector 参数配置敌人属性让“做新敌人”变成“配数据”而不是写新代码先明确一下本集的适用读者你已经能用Unity做角色移动、有基本的动画状态机概念跟着本系列前几集走到了现在。这套敌人框架写完你后续加Boss、加小怪、加精英怪都是在同一个骨架上长肉非常稳。我实测下来这个框架做完之后后续每加一个新敌人开发时间大概能缩短到原来的三分之一。2. 敌人AI的整体架构与状态机设计思路2.1 为什么要用状态机而不是“一堆 if else”新手写AI最容易陷入的写法是这样的Update里堆一堆布尔判断玩家进范围就追距离小于多少就攻击血量低了就逃跑。Demo前期敌人少看不出问题一旦敌人多了、行为复杂了这种写法就是灾难。状态机的核心价值在于它把“敌人此刻在做什么”和“敌人下一步要做什么”拆开管理。比如一个敌人处在 Patrol巡逻状态时它的移动逻辑是走到A点再走回B点一旦玩家进入索敌范围它切换成 Chase追踪移动逻辑就变成朝玩家方向走。这两种状态下Update里都在算移动但规则完全不同。用状态机做每个状态是独立的类或函数块代码边界清晰排查Bug的时候一眼能看出问题出在哪。对于空洞骑士这种魂Like风格的Demo敌人AI不需要多么复杂——原作里很多小怪就是“玩家靠近→出手”的固定套路。但状态机的好处是你以后想加复杂行为时不需要推翻重写只是在状态表里多插一个状态而已。2.2 敌人基类的职责边界我做这套框架时最纠结的就是“基类到底放多少东西”。放太少了子类重复代码多放太多了子类被基类绑死不好扩展。我最终敲定的职责划分是血量、受伤、死亡事件、击退向量这些所有敌人共用的放基类敌人自身的视觉表现Sprite、动画控制和移动逻辑速度、加速度通过虚拟方法让子类覆写每个状态的具体行为由状态机里的状态类负责基类只负责提供公共接口这样分的理由很简单未来加一个新敌人即使它的行为完全不同于现有敌人你也不需要动基类的代码只需要新建一个子类重写对应的方法就行。复杂度被状态机挡住了灵活性被子类继承保留了。我习惯把基类写到足够薄只放那些“不管什么敌人都必须要的东西”。这不是偷懒而是为了避免上帝类的出现——所有逻辑全堆基类里后面改一个功能可能影响一大片敌人。2.3 状态机的具体实现方式状态机的实现方案很多有基于枚举加Switch的、有基于状态模式类的、还有用Unity Animator来做“伪状态机”的。我在Demo里用的是状态接口 状态机管理器的方式清爽可控。public interface IEnemyState { void Enter(EnemyBase enemy); void Update(EnemyBase enemy); void Exit(EnemyBase enemy); } public class EnemyStateMachine { private IEnemyState currentState; public void ChangeState(EnemyBase enemy, IEnemyState newState) { if (currentState ! null) currentState.Exit(enemy); currentState newState; currentState.Enter(enemy); } public void Update(EnemyBase enemy) { if (currentState ! null) currentState.Update(enemy); } }这是最基础的版本简单的状态下就可以跑起来。后面如果有需要再加状态切换条件、状态缓存这些但Demo阶段先别过度设计把骨架子搭起来再谈优化。每个具体状态一个类文件比如巡视public class EnemyPatrolState : IEnemyState { private Vector2 pointA; private Vector2 pointB; private Vector2 targetPoint; public void Enter(EnemyBase enemy) { pointA enemy.patrolPointA; pointB enemy.patrolPointB; targetPoint pointA; enemy.SetFacingDirection(targetPoint - (Vector2)enemy.transform.position); } public void Update(EnemyBase enemy) { if (enemy.CanSeePlayer()) { enemy.StateMachine.ChangeState(enemy, new EnemyChaseState()); return; } enemy.MoveTo(targetPoint); if (Vector2.Distance(enemy.transform.position, targetPoint) 0.1f) { targetPoint targetPoint pointA ? pointB : pointA; enemy.SetFacingDirection(targetPoint - (Vector2)enemy.transform.position); } } public void Exit(EnemyBase enemy) { } }这里有个我踩过的坑转向逻辑用transform.localScale.x正负值控制翻转但敌人碰撞体是BoxCollider2D翻转之后如果碰撞体位置没跟着调会造成碰撞区域偏移。解决办法是把碰撞体做成子物体或者用transform.right控制朝向而不是直接改Scale。我后面会在细节部分再展开讲。3. 三个典型敌人从站桩怪到追踪怪的由简入繁3.1 第一个敌人守卫骑士站桩型这类敌人是空洞骑士里最常见的小怪类型——站在那里不动玩家靠近后有一次出手攻击然后进入冷却等待下一轮。它不需要巡逻也不需要追踪是学习状态机的最佳入门案例。实现它的状态表非常简Idle站着检测玩家是否进入攻击范围Attack播放攻击动画动画事件里生成攻击判定Cooldown攻击结束后进入冷却等待下一次这里最关键的是“攻击判定生成”的时机。如果直接让攻击动画播放期间整个碰撞体都开着玩家可能会在“动画前摇期间”就莫名其妙被打中体验很糟糕。空洞骑士之所以手感好是因为它的判定窗口非常精确甚至略微“延迟”了一点让玩家有反应时间。我用的是动画事件 Collider开关的方式在攻击动画的特定帧比如下劈动作第5帧添加一个Animation Event事件触发时启用攻击用子碰撞体碰撞体上挂一个EnemyAttackHitbox脚本处理伤害判定动画事件在快结束的帧再触发一次关闭碰撞体这样保证判定窗口和动画动作精确同步不会出现“动作还没到伤害已经出来了”的问题。3.2 第二个敌人巡逻骑士路径型巡逻骑士比站桩怪多了一个行为在两个路标点之间来回走。这个敌人本身复杂度不高但它引出了一个重要设计问题——状态切换的条件放哪里我早期的写法是在Patrol状态的Update里直接判断if (player进入范围) 切换到Chase结果后面所有状态都开始做这种判断状态之间互相耦合改一个判断条件要翻好几个文件。后来我调整了设计把“我能不能看到玩家”作为一个独立的检测函数放在基类里每个状态在Update的第一步都先做这个检测。这样虽然每次状态更新都多了一次判断调用但因为检测本身非常轻量判断距离 判断是否在视野范围内性能开销完全可以忽略。我建议你在这个Demo里就用这个习惯因为后续做Boss时状态切换条件的数量会成倍增加到时候再改架构代价就大了。巡逻骑士的实现还有一个细节它在巡逻过程中的朝向要做“拟人化”处理——不是走到点A瞬间转身走向点B而是放慢速度、先停一下再转身这样更有“活物感”。空洞骑士里的很多怪物都有这种小停顿玩家看到会觉得这个怪“有自己的节奏”。3.3 第三个敌人冲锋骑士追踪型带简单攻击前摇第三个敌人是Demo里第一个真正有“威胁感”的怪玩家接近后它会先蓄力发出提示性的光效或声音然后朝玩家当时所在位置发起冲锋。这个敌人的核心设计是攻击前摇Telegraph。很多新手做追踪型敌人直接就是“追着玩家跑贴身就攻击无前摇”。这样对玩家来说完全没有公平性可言——你跑不掉、反应不了、只能硬吃伤害。空洞骑士的风格是敌人的攻击前摇非常明显甚至可以说是“亲切的提示”玩家只需要学会节奏就一定能躲开。冲锋骑士的状态表是Idle站桩检测玩家Chase看到玩家后以中速靠近Telegraph进入攻击前摇身体闪白/蓄力持续0.8秒DashAttack向锁定方向高速冲锋触墙后结束Stunned冲锋结束后陷入短暂眩晕给玩家反击窗口这个设计里有一个非常关键的技术点锁定方向。冲锋方向是在Telegraph结束时锁定的不是实时跟踪玩家位置。也就是说玩家看到蓄力时如果开始移动是可以躲开这次冲锋的。实现上就是用一个Vector2 dashDirection变量在状态切换那一帧把(player.position - transform.position).normalized存进去之后DashAttack里只用这个固定方向。public class EnemyDashAttackState : IEnemyState { private Vector2 dashDirection; private float dashSpeed; public void Enter(EnemyBase enemy) { // 锁定方向 dashDirection ((Vector2)enemy.Player.transform.position - (Vector2)enemy.transform.position).normalized; dashSpeed enemy.dashSpeed; enemy.SetFacingDirection(dashDirection); } public void Update(EnemyBase enemy) { enemy.rb.linearVelocity dashDirection * dashSpeed; // 检测是否撞墙或超过最大距离 if (enemy.CheckWallHit() || enemy.CheckDashDistanceExceeded()) { enemy.StateMachine.ChangeState(enemy, new EnemyStunnedState()); } } }这个敌人在系列的Demo演示中非常出效果——玩家第一次遇到它会吃一次亏第二次就知道要提前走位了。这种“成长感”就是动作游戏的核心魅力而你是在用一套代码框架把它实现出来。4. 动画、攻击判定与受击反馈的实践4.1 动画状态机的配置方式敌人的动画控制我建议直接用Unity Animator用参数驱动状态切换。为每个敌人建一个以Italic命名的动画控制器里面至少有这样几个状态Idle默认Move巡逻/追踪时Attack攻击Hurt受伤Death死亡Animator参数我用的是isMoving、isAttacking、isHurt、isDead这几个布尔值再加上一个state整型参数来做精细控制。状态机里的过渡条件直接绑定这些参数。但有一点要特别注意Animator的状态切换和C#状态机的切换是两套系统不要试图把它们完全同步。我的做法是C#状态机负责“决策逻辑”该做什么Animator负责“表现逻辑”动画看起来像什么。两者通过参数单向通信C#状态机改了参数Animator响应变化。这样即使未来动画表现改了AI逻辑完全不受影响。一个常见的坑是Animator的Entry节点默认连到Idle如果你敌人出生时希望是巡逻状态逻辑上C#状态机已经切到Patrol但动画还是Idle。要解决用Animator.CrossFade或者把所有状态的初始过渡检查一下确保逻辑和表现是同步的。4.2 攻击判定的两种做法对比攻击判定这块我试过两种方案方案A碰撞体触发Trigger给敌人子物体挂一个BoxCollider2DIsTrigger true攻击动画播放到特定帧时启用结束后禁用。碰撞体挂脚本OnTriggerEnter2D里检测玩家然后调用玩家的受伤接口。优点是直观、适合近战攻击。缺点是多个敌人同时攻击时同一个玩家可能在短时间内收到多次伤害需要再加无敌帧逻辑Invisibility Frames简称IFrames来做伤害合并。方案B圆形范围检测Physics2D.OverlapCircle攻击动作发生时以敌人为圆心画一个扇形或圆形区域用物理查询检测是否有玩家在里面。优点是判定更灵活可以画出非矩形攻击范围也不会有多个碰撞体误触发的问题。缺点是判定基于物理查询如果玩家移动很快可能“穿过”判定区域导致没被打到。我的建议是近战敌人用方案A远程或者特殊攻击用方案B。Demo里三个敌人都用方案A配合玩家的无敌帧受伤后0.5秒内不再受到二次伤害效果已经够了。关于无敌帧我见过很多新手忘了做这个结果玩家贴脸站撸一个敌人时每秒掉5次血直接被秒杀。这个一定要做。实现很简单玩家受伤后设置一个invincibleTimer大于0时忽略所有伤害输入。角色动画上做一个闪烁提示让玩家感知到当前处在无敌状态。4.3 受伤反馈的三个层面一个敌人被打中时如果只是血条减少玩家会觉得“打空气”。空洞骑士这类游戏的受击反馈通常包含三个层面数值反馈飘伤害数字或者Boss血条减少位移反馈敌人向后击退一小段距离颜色/动画反馈敌人闪白或闪红播放受击动画我习惯的做法是基类的TakeDamage(int damage, Vector2 knockbackDirection, float knockbackForce)方法里统一处理血量和击退颜色反馈用协程做一个“闪白”效果——把Sprite的材质替换成带白闪效果的材质持续0.1秒后换回来。public virtual void TakeDamage(int damage, Vector2 knockbackDirection, float knockbackForce) { currentHealth - damage; StartCoroutine(FlashWhiteEffect()); rb.linearVelocity knockbackDirection * knockbackForce; if (currentHealth 0) { Die(); } else { StateMachine.ChangeState(this, new EnemyHurtState()); } }注意一点EnemyHurtState不是所有敌人必须的。站桩怪受了伤会硬直一下但冲锋骑士如果冲锋过程中受伤如果也强制进入Hurt状态会导致冲锋被打断这反而可能变成玩家“站桩打怪无脑输出”的Bug来源。这里的处理方式要根据敌人特性定制——冲锋骑士在DashAttack状态下受伤应该忽略硬直效果继续冲锋。这属于高层逻辑我会在基类里留一个虚函数给子类覆写。4.4 死亡处理与资源回收敌人死亡不只是播放一个动画那么简单。如果动画播完就销毁角色掉落、游戏计数、关卡重置都会有问题。死亡处理的标准流程是设置isDead true禁用AI更新不要再执行任何状态逻辑切换到Death动画状态用Destroy(gameObject, delay)延迟销毁保证死亡动画播完生成掉落物魂、金币、回复品——Demo里可以先不做但接口要先预留这里有一个新手不太会注意的点如果你的敌人是用对象池管理的Destroy之后直接激活下一个要确认死亡动画和状态机都完全重置了。我倾向于Demo阶段就别用对象池了直接Destroy即可等敌人数量真的成为性能瓶颈再优化也不迟——毕竟过早优化是开发效率的头号杀手。5. 视觉与场景细节阴影、灰烬与整体氛围5.1 阴影问题与Renderer的包围盒做这类2D游戏时光影往往是氛围感的关键。空洞骑士是横版2D室内场景大量使用暖色的烛光和冷色的环境光角色在光线下还有投影。Unity 2D的阴影通常用ShadowCaster2DURP环境实现。但这里有一个搜索引擎热词里频繁出现的问题——Unity阴影问题。我实测下来最常见的现象是阴影错位、阴影闪烁、阴影覆盖范围不对。阴影错位95%是因为Light2D和物体的SpriteRenderer不在同一个排序层或者物体的Local Position有带隐含的Z轴偏移。2D游戏里Z轴虽然看起来没什么用但它会直接影响渲染层级的判断。阴影闪烁则多半出在 ShadowCaster2D 的Shape没有正确包围Sprite——Unity需要根据Sprite的轮廓自动生成包围盒(它叫Renderer2D的包围盒)如果Sprite图集里有透明区域或空隙生成的包围盒就会偏大或偏小。解决办法很机械但有效把SpriteRenderer的Sprite设置干净一点尤其是网上找的素材把透明边缘裁干净再用ShadowCaster2D的“生成碰撞体”功能手动刷新一次包围盒。这类问题很细微调试时一度怀疑是Unity的版本Bug最后发现是素材边缘留了1像素的透明边导致包围盒计算错位。这类经验平时文档里根本不会写但你做多了2D游戏迟早会碰到。5.2 场景灰烬粒子与雾效空洞骑士的废土氛围给人很深的印象很大程度来自场景里飘散的灰烬粒子和远处的雾效。这些在Unity里实现非常便宜灰烬粒子用一个 ParticleSystem发射方向朝上或随风向斜飘粒子用淡色半透明的方形或者圆形Sprite开一点抖动和旋转配上低发射速率5~20个每秒就能营造出“空气中有细小微尘”的感觉。雾效则可以用一张半透明的深蓝色/灰色Sprite铺多层配一个很慢的横向移动脚本制造出远处的景深。注意雾效不要放到角色所在层之上——否则角色像被盖了一层纱。排序层上要把雾效放在“背景层”和“前景层”之间的一个独立层级。5.3 镜头的微小延迟带来的“手感”提升在做敌人视觉细节的时候我顺手调整了摄像机的跟随逻辑给相机加了一个极小的延迟和“死区”Dead Zone。原版空洞骑士的镜头也不是死死锁定玩家的它给玩家留了很大的视野余量。实现方法是在摄像机目标位置和当前位置之间做插值但不要让插值系数是常数——在玩家快速移动时插值速度慢一些玩家静止时镜头会慢慢“推盯”住玩家。这种方式在搜索引擎热词里搜“unity摄像机跟随”会有很多案例但核心思路就是镜头不要和玩家在像素级上完全同步留一点自由度玩家的眼睛才有“发现下一个区域”的期待感。6. 常见问题排查与性能优化的速查表6.1 敌人穿墙、卡墙怎么办做有巡逻路径的敌人时最常见问题是敌人走到障碍物前面会卡住或者直接穿过墙壁。前者一般是Collider问题后者一般是移动代码用了transform.position direction * speed而完全无视碰撞。解决方案有两条路一是用Rigidbody2DAddForce或者直接设置velocity让物理引擎处理碰撞。注意要把Rigidbody2D的gravityScale设为0因为横版游戏敌人不需要重力bodyType设为Dynamic碰撞体设为非触发器。二是继续用transform移动但每帧做Physics2D.Raycast检测前方是否有障碍物有就不移动。这个方案可控性更强但代码要写得多。我的建议Demo阶段的巡逻敌人用方案一就够了——直接用Rigidbody2D的velocity控制移动物理引擎会自动处理撞墙问题。唯一要小心的是Rigidbody2D的velocity在受到其他力比如击退之后要记得恢复否则击退完敌人会变成“滑行模式”。我在击退的实现上走的是在TakeDamage里直接设置rb.linearVelocity为击退方向然后在下一次State.Update里让目标状态重新控制rb.linearVelocity。这样“击退效果”只持续一个物理帧不会干扰到后续的移动逻辑。6.2 如何避开发射物“撞不到玩家”的问题如果后面加入远程敌人发射物比如骨钉做出来后经常出现“明明看着穿过了玩家身体却不触发伤害”的问题。这通常是因为发射物的移动速度太快FixedUpdate的物理检测间隔内发射物已经“穿过”了玩家的碰撞体。解决办法不要把发射物做成一个高速移动的Rigidbody而是改为“每个固定时间步做一次射线检测/圆形检测”来判断是否命中。或者干脆用Rigidbody2D的interpolation模式 较慢的速度。对Demo里的冲刺型敌人同样存在这个问题——冲锋速度很快时可能穿模。这里的解法是在rb.MovePosition而不是直接设置velocity的基础上每帧额外做一次Physics2D.OverlapCircle检测玩家是否在冲锋路径上。双保险基本不会漏判。6.3 性能优化把负担分摊到帧Demo里如果敌人数上来了尤其是多个敌人同时使用Physics2D.OverlapCircle或者多个动画同时播放时性能可能崩。测性能最直接的方法是看Profiler——哪个函数耗时最多一目了然。但更习惯的做法是“防患于未然”敌人的索敌检测不要每帧做。用一个InvokeRepeating每0.2秒做一次距离检测就够了人的反应速度根本感觉不出这0.2秒的差别多个敌人共享同一个材质实例不要每个敌人新建材质——否则Draw Call会翻倍动画上的Sprite不要用超大图集每个角色单独一张2048图集足够我在关卡中摆放敌人的原则是敌人激活范围之外根本不初始化AI只有玩家进入了某个“警戒区域”后敌人才开始跑状态机。实现方式是在敌人身上挂一个EnemyActivator组件用OnTriggerEnter2D判定玩家进场进场才把enabled设为true。这能省掉很多后台白白运行的空状态。6.4 遇到Bug先检查“是不是物理帧的问题”排查Bug时有个习惯如果你在Update里写的是“根据当前输入改变速度”却在FixedUpdate里读这个速度就会遇到一卡一卡的Bug。因为Update的频率和FixedUpdate的频率不同你无法保证读到的速度是“这一物理帧的最新值”。这类问题的根源在于Unity把“逻辑更新”和“物理更新”分开了。Input、动画一般在Update里写物理移动、碰撞响应在FixedUpdate里做。如果两者有依赖关系最好全部放到FixedUpdate或者用Time.deltaTime做时间缩放。流程上还有一个小技巧调试敌人AI遇到诡异行为时先检查是不是Animator把动画状态卡住了然后检查是不是OnTriggerExit2D这种物理回调改变了状态机最后才去怀疑逻辑层。因为物理回调触发时机不稳定是最容易藏Bug的地方。7. 后续扩展从“三个敌人”到“一个生态”这一集的三个敌人只是起步。做完这三个你已经有了敌人AI的完整骨架。接下来我个人建议的扩展路线是给敌人加掉落物系统魂、道具、回血这会让通关流程更有意义做一个简单的Boss战Boss可以复用状态机只是状态数量更多、切换条件更复杂加入“追击音乐”的切换触发器——玩家进入战斗区域时BGM切换这是营造紧张感很便宜的方式把敌人参数全部配置成ScriptableObject做一个“敌人图鉴”策划同学可以直接配表在Demo阶段这个框架里可能已经有能力做出一个看起来很完整、玩起来很有手感的小型类银河恶魔城了。不要急着往里面塞几十个敌人先把三个敌人打磨到“玩家能记住它们的招式”的程度这比堆数量有意义得多。做敌人AI最让我头疼又最让我上瘾的时刻是给冲锋骑士设计前摇的那一次。前摇持续0.8秒改成1.2秒玩家觉得过于简单改成0.5秒玩家觉得不公平。最后我调成0.8秒配上一声急促的提示音效那种“看到前摇就自动拉开距离”的反应和原版空洞骑士的体验就非常接近了。手感这回事没有一步到位的配方都是反复调出来的。8. 常见问题速查表问题现象可能原因排查思路与解决方案敌人卡在墙边不巡逻碰撞体或移动方式问题改用Rigidbody2D velocity移动确认重力为0攻击判定延迟严重动画事件帧位置不对打开Animation窗口把事件精确拖到挥击帧玩家连续受击被秒杀缺少无敌帧玩家受伤后设invincibleTimer期间忽略伤害敌人受击后滑行不止击退设置了velocity但没恢复状态切换时重新赋值velocity不要持续保留击退力冲锋型敌人穿模速度太快物理检测漏帧用MovePosition或额外做OverlapCircle检测阴影错位/闪烁ShadowCaster2D包围盒不对裁剪Sprite透明边缘重新生成碰撞体形状多个敌人同时行动性能掉帧索敌检测过于频繁0.2秒一次检测配合进入警戒区域再激活AI动画状态与逻辑状态不同步Animator参数没及时更新确认C#状态机和Animator之间的参数是单向驱动最后聊一个我在整个Demo开发过程中反复体会到的事情做敌人AI本质上是在“给玩家设计体验”不是在“给代码做功能”。你写一个前摇0.8秒的冲锋真正目的是告诉玩家“看清楚它的动作然后从容地躲开”。你写一个受击闪光是为了让玩家清晰地感知到“我这一刀打中了”。当开发视角从“我写出了这个功能”转变成“玩家体验到了这个设计”时游戏开发才算真正入了门。这一集的内容到这里就差不多了。先把守卫骑士做出来跑通整个流程觉得顺手了再加巡逻骑士最后挑战冲锋骑士。三只怪做完第四集的Demo就能交差了。