1. 从"会动的角色"到"会思考的敌人":第7篇要解决的核心问题
如果你跟我一样是照着2Dadventure这个系列一路做过来的,那前面的第1篇到第6篇大概已经把角色的移动、跳跃、动画切换、Tilemap关卡绘制、摄像机跟随、基础UI这一整套"能跑起来的冒险Demo"搭完了。但肯定有个很别扭的地方:场景里的敌人全是傻站着的,或者只会沿着一个方向来回走,玩家走过去碰一下掉血,然后对方就被撞死了——这哪叫冒险游戏,这叫碰碰车。
这个系列做到第7篇,我认为真正意义上的"冒险感"是从这一篇开始的。敌人得会巡逻、会追你、会攻击、会被打退、会掉东西,你得有一整套可复用的战斗交互框架。第7篇的内容不是零碎地加一两个脚本,而是要把"敌人AI"和"战斗反馈"这两个系统接进你已有的角色控制器和UI框架里。全文会围绕一套我实际在项目里验证过的方案来讲:有限状态机驱动的敌人基类、基于触发器加物理检测的感知系统、带无敌帧的战斗判定、对象池化的伤害飘字和掉落物。适合正在做横版2D冒险、对这种"敌人像活的一样"的效果有需求的开发者参考。
先给结论:如果你把敌人的行为决策、感知输入、行动输出三者强行塞进Update里写if-else,前两个敌人没问题,第五个开始失控。所以这篇的重点不是教你写某个具体敌人,而是教你搭一个"敌人的骨架",以后你加任何小怪、精英怪、Boss,都是在填充这个骨架。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 敌人状态机:别再堆if-else了,把行为拆成状态
2.1 状态划分的粒度:五个状态起步
拿最典型的近战小兵举例,我常用的状态粒度是这五个:
- Patrol(巡逻):在指定范围内来回走,或沿路点移动
- Chase(追击):发现玩家后追上去,同时判断距离决定下一步
- Attack(攻击):进入攻击范围后播放前摇、出手、后摇
- Hurt(受击):被玩家打中后播放硬直动画,短暂不进行攻击
- Death(死亡):播放死亡动画、关闭碰撞、生成掉落物
为什么是这个粒度而不是更细?因为状态机最怕的是"状态爆炸"。粒度越细,状态切换的逻辑越绕,调试的时候越容易在多个状态之间来回横跳。上面五个状态基本覆盖了横版2D冒险里八成敌人的行为需求,远程法师、飞行敌人、Boss战都能在这个骨架上扩展。
2.2 状态切换的数据流与触发条件
状态机本质上是一个"当前状态 + 切换条件"的模型。每次Tick只处理当前状态的逻辑,然后检查是否满足切到其他状态的条件。关键是这部分逻辑如果是散落的,时间越长越难维护,所以我把切换逻辑收敛在Update里集中管理:
csharp复制public abstract class EnemyBase : MonoBehaviour
{
public enum EnemyState { Patrol, Chase, Attack, Hurt, Death }
protected EnemyState currentState;
protected Rigidbody2D rb;
protected Animator animator;
protected Transform playerTransform;
[Header("状态切换参数")]
public float detectionRange = 5f; // 发现玩家的范围
public float attackRange = 1.5f; // 攻击范围
public float loseTargetRange = 8f; // 丢失玩家的范围
protected virtual void Update()
{
if (currentState == EnemyState.Death) return;
if (currentState == EnemyState.Hurt) return; // 受击时硬直,不切换
if (playerTransform == null) return;
float distanceToPlayer = Vector2.Distance(transform.position, playerTransform.position);
EvaluateStateTransition(distanceToPlayer);
ExecuteCurrentState(distanceToPlayer);
}
protected virtual void EvaluateStateTransition(float distance)
{
if (currentState == EnemyState.Attack) return; // 攻击过程中不切状态
if (distance <= detectionRange && distance > attackRange)
{
SetState(EnemyState.Chase);
}
else if (distance <= attackRange)
{
SetState(EnemyState.Attack);
}
else if (distance > loseTargetRange)
{
SetState(EnemyState.Patrol);
}
}
}
有一处需要特别说明:Hurt状态下我直接return了,因为受击硬直应该拥有最高优先级,玩家打中敌人后如果敌人还能继续攻击,整个战斗反馈的手感会非常差。Attack状态内部也要有"前摇-伤害判定-后摇"的子流程,这要在动画事件里配合完成,后面专门讲。
2.3 用Union做状态切换时的初始化清理
有个很常见但又容易漏的细节:每次切换状态,必须重置上一状态遗留的数据。比如从Attack切回Chase时,攻击的hasDealtDamage标志位必须清零,不然下次攻击动画还没播完就直接判定了。我在项目里是用一个简单的方法集中管理:
csharp复制protected virtual void SetState(EnemyState newState)
{
currentState = newState;
switch (newState)
{
case EnemyState.Patrol:
rb.velocity = Vector2.zero;
break;
case EnemyState.Chase:
animator.SetBool("isWalking", true);
break;
case EnemyState.Attack:
animator.SetBool("isAttacking", true);
hasDealtDamage = false;
break;
case EnemyState.Hurt:
animator.SetTrigger("hurt");
break;
case EnemyState.Death:
GetComponent<Collider2D>().enabled = false;
rb.velocity = Vector2.zero;
rb.gravityScale = 0f;
break;
}
}
Unity的Animator里Bool和Trigger混用是常事,但在状态切换时一定要想清楚:哪个参数是瞬时触发的,哪个是持续性的。isWalking是持续性标志位,切走的时候要在对应的目标状态里手动置回false,否则动画就会出现"角色明明站着不动,但播放的是走路动画"这种诡异现象。我早期经常遇到,后面养成"切换状态先重置所有Animator参数"的习惯才算根治。
2.4 状态机的可视化调试:Scene视图里直接画
调试敌人AI最痛苦的事情是"看不见它在想什么"。我的习惯是在敌人的头顶或者脚底把当前状态用GUI/TextMesh显示出来,同时用Unity的OnDrawGizmosSelected把几个范围圈画在Scene视图里。
csharp复制private void OnDrawGizmosSelected()
{
Gizmos.color = Color.yellow;
Gizmos.DrawWireSphere(transform.position, detectionRange);
Gizmos.color = Color.red;
Gizmos.DrawWireSphere(transform.position, attackRange);
Gizmos.color = Color.cyan;
Gizmos.DrawWireSphere(transform.position, loseTargetRange);
}
这三个圈一画出来,很多"敌人为什么不攻击""为什么在边界反复横跳"的问题,看一眼Scene视图就能定位。比如玩家明明在攻击范围内了但敌人不动,说明attackRange小于实际Sprite的视觉范围;敌人在两个状态间反复横跳,说明loseTargetRange和detectionRange之间没有留足迟滞区间。Range参数之间一定要留迟滞带,否则敌人会在边界处疯狂切换状态,表现得像抽风。
3. 感知系统:让敌人真正"看见"玩家
3.1 触发器方案和射线方案怎么选
发现玩家这件事有两个主流做法:一个是给敌人挂一个圆形或胶囊形Collider2D并设置为Trigger,玩家进入范围触发OnTriggerEnter2D;另一个是每帧做射线或物理检测。我的建议是:Trigge负责"大范围发现",射线负责"精确目标确认"。
只用一个Range Trigger的问题在于:墙体会干扰。地图里经常有挡在敌人和玩家之间的墙壁,如果敌人隔着墙也能"看到"玩家并且追过去,很出戏。只做射线的问题在于:玩家从身后靠近时射线方向反了,除非你每帧向各个方向发射,浪费性能。
我实际用的是混合方案:大圆Trigger只负责"附近有玩家吗",一旦触发了,再向玩家方向打一条射线,检查中间是否有遮挡物。遮挡物用单独的Layer来管理,比如Obstacle层,射线只跟这一层做碰撞检测。
3.2 视线遮挡检测的层遮罩配置
如果你把所有Map物件都放在Default层,那么射线会跟敌人自己的Collider碰撞,也会跟地上的平台碰撞,检测结果会乱套。所以我建议单独建一个Obstacle层,只给墙壁、柱子这类"能遮挡视线"的物体挂。检测代码很简单:
csharp复制[Header("感知参数")]
public LayerMask obstacleMask;
public Vector2 eyeOffset = new Vector2(0f, 0.5f); // 视线的原点偏移,避免打到地面
private bool HasLineOfSight(Transform target)
{
Vector2 origin = (Vector2)transform.position + eyeOffset;
Vector2 direction = (Vector2)target.position + new Vector2(0f, 0.5f) - origin;
RaycastHit2D hit = Physics2D.Raycast(origin, direction.normalized, direction.magnitude, obstacleMask);
return hit.collider == null;
}
eyeOffset这个细节很多人会忽略。如果你直接从transform.position发射射线,玩家的胶囊碰撞体底部会先被地面挡住,敌人永远"看不见"玩家。把射线的原点稍微抬高到"眼睛"的位置,才符合直觉。
3.3 丢失目标的迟滞逻辑
一个很容易犯的错误是:玩家进入detectionRange就开始追击,但只要玩家往后退了一点点脱离范围,敌人立刻回到巡逻状态。这会导致敌人在边界上来回抽风,疯狂在Patrol和Chase之间切换。
处理办法就是前面提到的loseTargetRange。进入追击的阈值是5,丢失目标的阈值是8,中间这3个单位就是迟滞区间。玩家只要不是彻底跑远,敌人就会一直保持追击状态,手感一下就稳了。这也是真实生物行为的体现——你被敌人发现了,退后一步它还是追你。
3.4 追击时的路径归中:防止敌人被卡墙角
横版2D里通常不做复杂的寻路,最简单的追击就是用MoveTowards往玩家水平方向移动。但如果你只有地面障碍物,敌人往往会因为平台的边缘而走到墙边卡住。我的经验是:追击的同时加一个平台边缘检测,前方没有地面就停止追击并且转向巡逻,避免敌人掉出关卡。
csharp复制[Header("巡逻参数")]
public float edgeCheckDistance = 0.6f;
public LayerMask groundMask;
private bool IsGroundAhead()
{
Vector2 origin = (Vector2)transform.position + new Vector2(transform.localScale.x * edgeCheckDistance, 0f);
RaycastHit2D hit = Physics2D.Raycast(origin, Vector2.down, edgeCheckDistance, groundMask);
return hit.collider != null;
}
请注意这里的transform.localScale.x,因为2D敌人一般会在Flip面朝方向时翻转scale,所以前方检测也要跟着翻转方向走。关于Flip,我强烈建议用修改localScale.x来实现,不要用SpriteRenderer.flipX。因为碰撞体和伤害判定区域如果也要跟随翻转,localScale.x翻转会一并处理,而flipX只改显示不改物理。
4. 战斗交互:攻击判定、伤害数值和受击反馈
4.1 近战攻击判定的两种方式对比
玩家和敌人的近战攻击判定方法没有本质区别,常见的有两种:
- OverlapBox/OverlapCircle检测:攻击瞬间在武器前方生成一个检测区域,找出区域里的目标,施加伤害。
- 碰撞体加OnTriggerEnter2D回调:攻击时激活一个Trigger碰撞体,物体进入时触发伤害。
我倾向于前者,因为后者容易受到上一帧残留碰撞的影响。比如你前一次攻击后碰撞体还没来得及禁用,玩家恰好在这个间隙走进来就会受伤,体验很差。用OverlapBox是纯代码控制,攻击动画的某一帧调用一次,逻辑最干净。
csharp复制public void DealMeleeDamage(Vector2 center, Vector2 size, float angle, float damage)
{
Collider2D[] hits = Physics2D.OverlapBoxAll(center, size, angle, targetMask);
foreach (Collider2D hit in hits)
{
if (hit.TryGetComponent(out IDamageable damageable))
{
damageable.TakeDamage(damage, transform.position);
}
}
}
4.2 组件接口设计:IDamageable
为了让玩家、敌人、甚至炸药桶、可破坏场景物件都能共享一套受伤逻辑,我强烈建议定义一个接口:
csharp复制public interface IDamageable
{
void TakeDamage(float damage, Vector2 hitFrom);
}
玩家和敌人都实现TakeDamage,但内部逻辑各不相同。敌人收到伤害后进入Hurt状态,播放受击动画,并且有一个基于hitFrom的击退方向;玩家收到伤害后扣血、进入无敌帧、屏幕边缘泛红。这套接口设计让战斗系统具备了"我打你、你打我、我打箱子、箱子爆炸炸你"的复用能力,后面加Boss、加陷阱都是五分钟的事。
4.3 无敌帧和受击击退:手感的灵魂
如果玩家被攻击后没有无敌帧,Boss的持续伤害判定会让你在0.1秒内掉光血,因为OverlapBoxAll在连续几帧内都会检测到同一个碰撞体。处理办法是玩家受伤后设置一个invincibleTimer,在计时结束前忽略所有伤害。我习惯是0.5秒到1秒,有视觉闪烁配合。
击退的实现也有讲究。位移不要直接放进Update里每帧计算,而是在受伤瞬间给一个初速度,然后让刚体在摩擦力或者专门设置的衰减中自然减速:
csharp复制public virtual void ApplyKnockback(Vector2 hitFrom, float force)
{
Vector2 knockDirection = (transform.position - (Vector3)hitFrom).normalized;
rb.velocity = new Vector2(knockDirection.x * force, rb.velocity.y);
}
注意这里knockDirection.y我故意没用,因为如果击退把敌人推到空中会打乱后续AI判定。水平方向击退就够了,竖直方向保留刚体原本的重力速度。
4.4 伤害飘字:对象池是必选项
伤害飘字如果在每个受击单位身上动态Instantiate,场景里敌人一多就会频繁创建销毁。我的方案是做一个简单的对象池,预创建10个飘字物体隐藏起来,受伤时从池里取一个,显示数字后播放完动画再还回去。配合TMP的DOFloat或一个简单的协程做上浮和淡出,效果很干净。
飘字的位置还需要注意:如果每帧跟随敌人,飘字会飘到奇怪的方向。我建议飘字生成后锁定位置,然后用协程让它自己慢慢上移和淡出,不要依赖父对象。
csharp复制public class DamagePopup : MonoBehaviour
{
private TextMeshPro tmp;
public void Show(float damage)
{
tmp.text = damage.ToString("0");
transform.position = transform.position + new Vector3(Random.Range(-0.3f, 0.3f), 0.3f, 0f);
StartCoroutine(FadeAndDeactivate());
}
private IEnumerator FadeAndDeactivate()
{
float elapsed = 0f;
float duration = 0.6f;
Color startColor = tmp.color;
Vector3 startPos = transform.position;
while (elapsed < duration)
{
elapsed += Time.deltaTime;
tmp.color = new Color(startColor.r, startColor.g, startColor.b, 1f - elapsed / duration);
transform.position = startPos + Vector3.up * elapsed * 1.2f;
yield return null;
}
gameObject.SetActive(false);
}
}
这个脚本虽然不是世界上最高级的飘字,但胜在零依赖、可复用。你只需要在对象池框架里取用和归还即可。
5. 掉落物与经验拾取:冒险游戏的正反馈循环
5.1 掉落生成的随机偏移和消失计时
敌人死亡后掉落物如果直接生成在敌人死亡位置,玩家走过去捡的时候总觉得"不太真实"。我习惯是在掉落瞬间给掉落物一个随机的水平初速度,让金币或经验球做一个小跳,落地后自然静止。这样打掉敌人后满屏小东西弹出的小小爽感,是玩家持续打怪的动力之一。
掉落物还要设置一个despawnTimer,比如30秒后如果没被捡走就自动消失。不然场景里堆积的掉落物会越来越多,影响性能也影响视觉。
5.2 磁吸拾取:金币自动飞向玩家
掉落物有三种拾取方式:碰到就捡、按钮捡、吸附捡。"碰到就捡"在2D冒险里最省事,但有个小改进很值得做——范围拾取加磁吸。玩家靠近掉落物到一个较小范围内,掉落物开始以加速曲线飞向玩家,飞进碰撞体后再被收集。这个效果带来的流畅感远超代码的复杂程度。
实现是用一个半径比较大的Trigger范围,掉落物在范围内时给刚体一个朝向玩家的吸引力:
csharp复制private void Update()
{
if (isAttracted)
{
Vector2 direction = (playerTransform.position - transform.position).normalized;
rb.velocity = direction * magnetSpeed;
}
}
private void OnTriggerEnter2D(Collider2D other)
{
if (other.CompareTag("Player"))
{
isAttracted = true;
}
}
这里magnetSpeed我建议设成从8逐渐加速到15的曲线,太匀速了会显得呆板。
5.3 经验值到经验条:UI怎么刷新
经验拾取后要更新UI。我不建议在掉落物脚本里直接调用UI类,因为掉落物跟UI耦合后,后续你加多人在线、加存档系统会被这个耦合坑到。更好的办法是掉落物把经验值传给玩家的PlayerLevel组件,该组件对外暴露事件OnExperienceChanged,UI只监听事件:
csharp复制public class PlayerLevel : MonoBehaviour
{
public event Action<float, float> OnExperienceChanged; // currentExp, maxExp
public void AddExperience(float amount)
{
currentExp += amount;
while (currentExp >= maxExp)
{
currentExp -= maxExp;
level++;
maxExp = CalculateNextLevelMaxExp(level);
}
OnExperienceChanged?.Invoke(currentExp, maxExp);
}
}
这样做的好处是UI脚本完全被动,你测试数值、做存档加载、甚至做作弊指令加经验,UI都会自动跟随刷新,不用在多个地方手动调用UpdateExperienceBar。事件驱动在游戏UI这块真的是用了就回不去。
5.4 掉落物对象的池化与敌人配置的分离
掉落物的生成配置我建议放到敌人Inspector上可调,而不是像网上很多Demo那样在敌人身上写死"掉落5个金币"。实际项目里,不同敌人的经济价值、掉落物品概率都不一样,你大概率会需要在编辑器里反复调。我的做法是给EnemyDeathDrop单独做一个组件,暴露一组可配置项:金币数量范围、经验值范围、特殊掉落物概率。敌人死亡时由这个组件统一处理掉落逻辑,EnemyBase只负责广播死亡事件。
这么拆,后续你要做"精英怪必掉钥匙""Boss必掉宝箱"这些逻辑,都不需要改EnemyBase的代码,加一个掉落配置就行。
6. 踩坑记录:这套AI系统在真机测试中遇到的5个问题
6.1 Animator参数残留:攻击前摇失效
我第一个遇到的是经典问题:敌人从Chase切到Attack,isAttacking设为true,但播放的是Idle动画。查了半天发现是Animator里Chase -> Attack的Transition没有设对,而且上一状态的isWalking没有置回false,两个Bool同时为true导致状态机混乱。
排查办法是在Animator窗口里右键Any State看看是否有一条异常连线。我的建议是给每个状态之间显式连线,不要依赖Any State去处理频繁切换的状态,除非是死亡、受击这类可以随时打断当前动作的状态。同时也建议在SetState里先统一把所有参数清一遍再设为新值。
6.2 敌人掉落平台边缘:刚体休眠与卡墙
敌人巡逻到平台边缘时有时候会悬在半空不掉下去,检查后发现是Rigidbody2D的Sleep Mode设置为Start Awake导致它从不休眠,同时又受到平台边缘Collider的微小穿透影响,位置一直在抖动却不掉落。正确做法是Sleep Mode使用Never Sleep或者Based On Transform,并且有个细节——敌人的碰撞体在刚体上尽量设在子物体而不是根物体,这样可以避免检测自身碰撞。
6.3 事件重复注册:回菜单再进游戏,敌人行为加倍
这个坑很隐蔽:如果PlayerLevel的OnExperienceChanged事件在OnEnable里订阅,在OnDisable里取消订阅,每次进出场景都应该没有泄漏。但我当时偷懒只在Start里订阅,没在OnDestroy里退订,结果敌人死亡时经验值事件被触发多次,UI飘字会重叠。排查这个问题的办法很简单:给事件加个日志,在触发时打印订阅数量。后来我总结的通用规则是:所有addListener都要有对应的removeListener,订阅放OnEnable,退订放OnDisable,Unity生命周期里这两个方法是成对调用的,跟场景加载卸载完全吻合。
6.4 对象池容量不足:掉落物消失后内存不释放
对象池如果固定预创建10个掉落物,而场景里一次出现15个掉落,多出来的5个会被强制创建新实例。打完之后前10个被"归还"了,后5个却仍然留在场景里,不会自动消失。如果场景循环多次,这些遗漏对象越积越多。
修复思路是给Get方法加一个createIfEmpty参数。如果池空了而且允许创建就创建,不允许就直接返回一个不活跃的对象或者从最老的对象里回收。更合理的做法是:每次从池里取对象时都重置所有状态,包括位置、速度、存活计时器,防止上一个对象的状态残留到下一个。
我用过一个比较笨但有效的办法:给每个池化对象的类加一个ResetState()方法,在SetActive(true)之后调用。这个方法里把despawnTimer重置、isAttracted置为false、速度清零。虽然代码多一点,但能根治状态残留导致的很多诡异Bug。
6.5 Trigger碰撞体与刚体没有同步:感知范围失灵
还有一个比较坑的细节:如果敌人的感知Trigger放在子物体上,但子物体没有Rigidbody2D,Trigger的OnTriggerEnter2D回调有可能不触发。Unity的2D物理要求:进行Trigger检测的两个物体里至少有一个带有Rigidbody2D。我在做"敌人发现玩家后身体变大,感知范围跟着变"这个功能时就踩过这坑,直接把Trigger放在子物体上,然后子物体没有加刚体,结果发现进入范围了却完全没反应。解决办法是在子物体上也挂一个Rigidbody2D并设为Kinematic,或者干脆把感知范围放在根物体上动态调整CircleCollider2D.radius。
7. 这一套框架对后续项目的复用价值
这篇从零搭起来的敌人AI和战斗交互框架,不只是给2Dadventure系列用的。我后来做另一个俯视角射击Demo、一个卡牌动作原型,都把EnemyBase、IDamageable、对象池这三块直接拖过去用,改造量其实不大。这正好验证了一个观点:游戏功能千变万化,但系统骨架是通用的。你在第七篇投入的时间,后面会以十倍回报回来。
如果想继续扩展,可以从这几个方向接着做:给敌人AI加真正的分页寻路算法,或者用NavMesh在2D地图上做回避障碍的追击;把IDamageable扩展到可破坏场景物件,做出打破木桶掉金币的效果;再往下可以把掉落物系统连接到装备商店,形成经济闭环。每个方向都是独立的专题,都不影响你这套骨架的稳定性。
最后说一个我实操中养成的习惯:写敌人AI时,优先把"可配置"做到位,数值、范围、速度全部暴露在Inspector上,然后打开Gizmos一面调一面看。花十分钟把调试工具做扎实,能省掉后续几十个小时的"盲调"时间。希望这篇的经验能让你少踩几个我踩过的坑。
