拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

Unity 2D动作游戏敌人AI框架:从状态机到四类敌人实现全解析

Unity 2D动作游戏敌人AI框架:从状态机到四类敌人实现全解析

做Unity Demo复刻空洞骑士这个系列,终于走到第四集了。前几集我们把主角的白条、下劈、冲刺这些手感调得差不多,这一集开始往地图里塞敌人,而且不止塞一种,我打算把近战、飞行、远程、守卫这四类全做出来。

做敌人这件事,看起来就是摆个模型、写个AI、给个攻击碰撞体,但真正动手后发现坑不少:怎么让敌人自然巡逻不穿墙、怎么判断玩家在背后还要不要回头、怎么让飞行敌人既飘忽又不鬼畜、攻击判定用触发器还是射线、还有那些烦人的阴影剔除问题。这一集我把自己实现敌人系统的完整思路、代码、以及踩坑记录都整理出来,给同样在折腾Unity Demo的朋友做个参考。

这一集的内容适合两类人看:一是刚开始用Unity做2D横版动作游戏、想搭一套可复用的敌人框架的新手;二是已经会写简单AI、但想把这些逻辑整理得更工程化的人。我会从敌人类别划分开始讲,然后给抽象基类、状态机、巡逻索敌、攻击判定、死亡反馈这些核心环节逐步拆解,最后附上常见问题排查表,基本上照着做就能把这集的敌人系统跑起来。

1. 敌人类别划分与制作顺序

1.1 为什么先把敌人分成四类

空洞骑士里敌人种类非常多,虫子的、植物的、骑士的、梦境世界的,但抛开美术差异,敌人行为模式其实就那么几类:在地面来回走、在空中飘、站桩远程射、原地当门神。把行为模式抽象成类别,最大的好处是后续加新敌人只需要换数值、换动画、换外观,逻辑代码不用重写。

我在这集Demo里分了四类:

  • 近战巡逻型:地面来回走,进入索敌范围后追玩家,追上就攻击。这类是最基础的敌人,也是玩家最先遇到的。
  • 飞行型:不落地,在一个水平范围内上下浮动,朝玩家位置缓慢飘动,碰到玩家造成伤害。后期可以加远程弹幕,但第一版先做接触伤害。
  • 远程射手型:站桩或小范围移动,看到玩家后发射直线弹幕,有明显的出手前摇,给玩家反应空间。
  • 守卫型:不巡逻,固定在一个点,玩家靠近到一定距离就触发攻击或冲撞,用于关卡关键节点制造压力。

这四类不是拍脑袋定的。空洞骑士的关卡节奏一般是:普通杂兵铺垫(近战巡逻)→ 空中骚扰(飞行型)→ 远程压力(射手)→ 关键路口的强敌(守卫)。Demo第四集把这几类做齐,后面做关卡拼接就顺手了。

1.2 当前Demo实际要做的敌人清单

考虑到是Demo,我并没有给每个类别做多个敌人,而是每类先做一只“模板怪”,验证完逻辑再复制改数值。

具体清单如下:

类别敌人名称(临时)血量伤害行为特点
近战巡逻Walker_Bug31平地往返巡逻,索敌半径2.5格,追击速度略高于玩家走路速度
飞行Flyer_Moth21不落地,挂在一定高度,左右飘动,缓慢朝玩家方向逼近
远程Shooter_Snail31站桩,玩家进入3.5格射程后蓄力0.6秒,发射一颗水平弹丸
守卫Guard_Knight51固定不动,玩家进入2格内向前冲撞,冲撞距离3格,有冷却时间

这些数值都写进了ScriptableObject或者直接在Inspector面板里调,方便后面找手感。做动作游戏最忌讳数值写死在代码里,每次调手感都要开脚本改,烦死。

2. 敌人AI框架设计

2.1 状态机是优先级最高的决定

敌人逻辑我这次没有用协程乱写,而是老老实实用了状态机。理由很简单:协程堆多了你根本不知道现在敌人到底在干什么,状态机至少一眼能看到当前状态,调试效率高很多。

我的状态机做得比较轻量,没有引入任何第三方插件,就是用枚举加switch。每个敌人维护一个currentState字段,Update里根据状态执行对应的逻辑方法,状态切换就改枚举值,并在切换时调用OnStateEnter和OnStateExit做初始化。

基础状态枚举是:

public enum EnemyState { Idle, Patrol, Chase, Attack, Hurt, Dead }
  • Idle:待机,主要用于远程敌人和守卫,等玩家靠近。
  • Patrol:巡逻,沿巡逻点往返走。
  • Chase:追击,朝玩家方向移动。
  • Attack:攻击,执行攻击动画和伤害判定。
  • Hurt:受伤,短暂硬直,无敌帧。
  • Dead:死亡,播放死亡动画并销毁。

每个敌人可以按需使用这几个状态,不一定要全用。比如守卫不用Patrol,飞行敌人直接Idle/Chase/Attack切换。

2.2 抽象基类Enemy的公共字段

所有敌人继承一个抽象基类Enemy,基类里统一管血量、受伤、死亡、公共物理参数。

字段包括:

[Header("Stats")] public float maxHealth = 3f; public float moveSpeed = 2f; public int contactDamage = 1; [Header("Detection")] public float detectRange = 2.5f; public float attackRange = 0.8f; public LayerMask playerLayer; [Header("State")] protected EnemyState currentState; protected Rigidbody2D rb; protected Animator anim; protected SpriteRenderer sprite; protected Transform player;

这里面有几个细节:

  • playerLayer用LayerMask而不是GameObject.Find("Player"),一是性能好,二是后面换Team管理多个角色也方便。
  • player引用在Start里通过GameObject.FindGameObjectWithTag("Player")拿,拿到后缓存,不要在Update里反复找。
  • rb用Rigidbody2D而不是直接改Transform,这样敌人能参与物理碰撞,不会穿墙也不会被卡进地形。

基类还实现统一的受伤和死亡入口:

public virtual void TakeDamage(float damage) { if (currentState == EnemyState.Dead) return; maxHealth -= damage; if (maxHealth <= 0) { Die(); } else { currentState = EnemyState.Hurt; anim.SetTrigger("hurt"); } } protected virtual void Die() { currentState = EnemyState.Dead; anim.SetTrigger("die"); GetComponent<Collider2D>().enabled = false; rb.velocity = Vector2.zero; // 延迟销毁,给死亡动画播放时间 Destroy(gameObject, 0.5f); }

这一套统一入口设计好之后,后面主角的攻击脚本只需要调用enemy.TakeDamage(damage)就行,不用关心敌人内部怎么处理。

2.3 用Gizmos把巡查点画出来

巡逻逻辑要配置巡查点,直接在场景里拖空物体做点不直观,我写了Gizmos绘制代码,在Scene视图中把巡逻路线和各个判定范围都画出来。

private void OnDrawGizmosSelected() { Gizmos.color = Color.yellow; Gizmos.DrawWireSphere(transform.position, detectRange); Gizmos.color = Color.red; Gizmos.DrawWireSphere(transform.position, attackRange); }

飞行敌人和远程敌人也各自画对应范围。这个习惯真的强烈推荐,否则你根本看不清索敌范围调到了多少,纯靠直觉在Inspector里拉数值,拉半天都找不对手感。

3. 核心代码实现

3.1 Enemy基类完整代码

先把基类完整代码贴出来,我删掉了项目里一些美术资源引用,保留了核心逻辑,直接复制到你的项目里改改字段就能用。

using UnityEngine; public enum EnemyState { Idle, Patrol, Chase, Attack, Hurt, Dead } [RequireComponent(typeof(Rigidbody2D), typeof(Animator), typeof(Collider2D))] public abstract class Enemy : MonoBehaviour { [Header("Stats")] public float maxHealth = 3f; public float moveSpeed = 2f; public int contactDamage = 1; [Header("Detection")] public float detectRange = 2.5f; public float attackRange = 0.8f; public LayerMask playerLayer; [Header("State")] protected EnemyState currentState = EnemyState.Idle; protected Rigidbody2D rb; protected Animator anim; protected SpriteRenderer sprite; protected Transform player; protected float stateTimer; protected virtual void Awake() { rb = GetComponent<Rigidbody2D>(); anim = GetComponent<Animator>(); sprite = GetComponent<SpriteRenderer>(); } protected virtual void Start() { player = GameObject.FindGameObjectWithTag("Player")?.transform; } protected virtual void Update() { stateTimer -= Time.deltaTime; switch (currentState) { case EnemyState.Idle: UpdateIdle(); break; case EnemyState.Patrol: UpdatePatrol(); break; case EnemyState.Chase: UpdateChase(); break; case EnemyState.Attack: UpdateAttack(); break; case EnemyState.Hurt: UpdateHurt(); break; case EnemyState.Dead: UpdateDead(); break; } } protected abstract void UpdateIdle(); protected abstract void UpdatePatrol(); protected abstract void UpdateChase(); protected abstract void UpdateAttack(); protected abstract void UpdateHurt(); protected abstract void UpdateDead(); protected virtual bool IsPlayerInRange(float range) { if (player == null) return false; float distance = Vector2.Distance(transform.position, player.position); return distance <= range; } protected virtual void FacePlayer() { if (player == null) return; float dir = player.position.x - transform.position.x; if (dir > 0.1f) sprite.flipX = false; else if (dir < -0.1f) sprite.flipX = true; } public virtual void TakeDamage(float damage) { if (currentState == EnemyState.Dead) return; maxHealth -= damage; if (maxHealth <= 0) { Die(); } else { currentState = EnemyState.Hurt; anim.SetTrigger("hurt"); stateTimer = 0.3f; } } protected virtual void Die() { currentState = EnemyState.Dead; anim.SetTrigger("die"); GetComponent<Collider2D>().enabled = false; rb.velocity = Vector2.zero; rb.simulated = false; Destroy(gameObject, 0.6f); } protected virtual void UpdateHurt() { if (stateTimer <= 0) { currentState = EnemyState.Idle; } } protected virtual void UpdateDead() { } }

里面把Hurt的恢复逻辑放在了基类,因为大部分敌人受伤后都是硬直一下然后回到待机/巡逻,少部分需要特殊处理再override。

3.2 近战巡逻敌人实现

近战敌人的脚本继承Enemy,加上巡逻点、追击、攻击三个模块。

完整代码:

using UnityEngine; public class WalkerBug : Enemy { [Header("Patrol")] public Transform[] patrolPoints; public float patrolWaitTime = 0.5f; private int patrolIndex; private float waitTimer; [Header("Chase")] public float chaseSpeed = 3f; protected override void Start() { base.Start(); currentState = EnemyState.Patrol; } protected override void UpdateIdle() { if (IsPlayerInRange(detectRange)) { currentState = EnemyState.Chase; } } protected override void UpdatePatrol() { if (patrolPoints.Length == 0) { currentState = EnemyState.Idle; return; } // 如果进入索敌范围,切到追击 if (IsPlayerInRange(detectRange)) { currentState = EnemyState.Chase; return; } // 等待时间先走完 if (waitTimer > 0) { waitTimer -= Time.deltaTime; rb.velocity = new Vector2(0, rb.velocity.y); return; } Transform targetPoint = patrolPoints[patrolIndex]; float direction = targetPoint.position.x - transform.position.x; float moveDir = Mathf.Sign(direction); rb.velocity = new Vector2(moveDir * moveSpeed, rb.velocity.y); sprite.flipX = moveDir < 0; if (Mathf.Abs(direction) < 0.2f) { waitTimer = patrolWaitTime; patrolIndex = (patrolIndex + 1) % patrolPoints.Length; } } protected override void UpdateChase() { // 超出追击距离太远,放弃追击回巡逻 if (!IsPlayerInRange(detectRange * 1.5f)) { currentState = EnemyState.Patrol; return; } // 到达攻击范围,切攻击 if (IsPlayerInRange(attackRange)) { currentState = EnemyState.Attack; rb.velocity = Vector2.zero; return; } float dir = player.position.x - transform.position.x; float moveDir = Mathf.Sign(dir); rb.velocity = new Vector2(moveDir * chaseSpeed, rb.velocity.y); sprite.flipX = moveDir < 0; } protected override void UpdateAttack() { anim.SetTrigger("attack"); // 攻击动画播放完后在动画事件里调用 AttackHit() } public void AttackHit() { // 动画事件调用,检测攻击范围内玩家并造成伤害 if (player == null) return; float distance = Vector2.Distance(transform.position, player.position); if (distance <= attackRange + 0.3f) { player.GetComponent<PlayerHealth>()?.TakeDamage(contactDamage); } } public void AttackFinished() { if (IsPlayerInRange(detectRange)) currentState = EnemyState.Chase; else currentState = EnemyState.Patrol; } protected override void UpdateHurt() { base.UpdateHurt(); rb.velocity = Vector2.zero; } }

这里面有两个点是实际调了很久的:

第一个是追击脱离范围要乘一个系数。如果追击距离和索敌距离一样,就会出现一个很蠢的情况:敌人刚追上你,你往后退一步,敌人立刻脱离追击状态回巡逻,然后你往前一步它又追,来回横跳。我把脱离距离设成索敌距离的1.5倍,形成“追击滞回”,手感好很多。

第二个是攻击状态的切换时机。攻击动画没播完不能切换到别的状态,否则动画会鬼畜。我在动画事件里放了AttackFinished(),只有攻击动画完整播完、事件触发后,才允许切回追或者巡逻,这样就不会出现攻击动作做到一半被中断的廉价感。

3.3 攻击判定与动画事件配置

Unity里攻击判定方式有好几种:碰撞体触发器、射线检测、直接距离判断。我在Demo里用了距离判断,因为这段代码最直白,对新手最友好。

上面的AttackHit()是动画事件,在Animation窗口里给攻击动画的某一帧添加事件,事件名称写AttackHit,播放到那一帧就会调用同名方法。

具体配置路径:

  1. 选中敌人的Animator Controller,找到攻击状态(AttackState)。
  2. 打开Animation窗口,选中攻击动画片段。
  3. 在时间轴大约播放到50%位置(每个人动画不一样,卡在攻击动作最“打出去”的那一帧)右键添加Animation Event。
  4. 函数名填AttackHit。

这里有个细节:判定范围要比攻击动画实际显示的攻击距离稍微大0.2到0.3个单位。因为玩家看到的攻击是一回事,实际手感又是一回事,稍微放宽一点判定,玩家会感觉更爽。但是不能放宽太多,否则玩家会明显觉得“我明明躲开了还是被打到”,那是反向优化。

空心骑士这种游戏,攻击判定的核心原则是:伤害宁可来得准,也不可来得宽。玩家死的时候判定特别宽松会让人觉得骗人,玩家打敌人时判定宽松则会觉得很舒服。所以敌人打玩家的判定我卡得比较严,玩家的攻击判定我会在后续自己做的时候稍微放宽。

3.4 飞行单位的悬挂与上下浮动

飞行敌人是另一个难点,因为2D横版游戏里飞行敌人不跟着物理重力走,但又要有自然的飘动效果,不能像贴图一样硬平移。

我的实现方式:

public class FlyerMoth : Enemy { public float floatAmplitude = 0.5f; public float floatFrequency = 2f; public float chaseSpeed = 1.5f; private Vector3 startPos; private float time; protected override void Start() { base.Start(); currentState = EnemyState.Chase; startPos = transform.position; rb.gravityScale = 0f; rb.constraints = RigidbodyConstraints2D.FreezePositionY | RigidbodyConstraints2D.FreezeRotation; } protected override void UpdateIdle() { } protected override void UpdatePatrol() { } protected override void UpdateChase() { if (player == null) return; time += Time.deltaTime; float targetX = player.position.x; float targetY = startPos.y + Mathf.Sin(time * floatFrequency) * floatAmplitude; Vector2 target = new Vector2(targetX, targetY); Vector2 dir = (target - (Vector2)transform.position).normalized; rb.velocity = dir * chaseSpeed; } protected override void UpdateAttack() { // 飞行敌人用接触伤害,不需要攻击状态 currentState = EnemyState.Chase; } private void OnCollisionStay2D(Collision2D collision) { if (collision.gameObject.CompareTag("Player")) { collision.gameObject.GetComponent<PlayerHealth>()?.TakeDamage(contactDamage); } } }

关键点:

  • 把Y轴位置冻结,同时重力改为0,这样飞行敌人不会被物理引擎拉下去。
  • 上下浮动用Sin函数,专业术语叫简谐运动,振幅和频率两个参数足够调出各种飞行手感。想飘忽一点就加大振幅减小频率,想稳定一点就反过来。
  • 目标X是玩家位置,目标Y是浮动中心,这样敌人会一边水平逼近一边上下起伏,看着像真实飞虫,实际上逻辑很简单。

接触伤害用OnCollisionStay2D做,注意加了CompareTag判断。这里有个细节,主角在受伤后应该有几帧无敌,否则OnCollisionStay2D每帧触发一次,玩家会瞬间被扣好几血。无敌帧放在PlayerHealth里,用协程或者计时器做,这个是下一集做战斗反馈时的事。

3.5 远程射手的蓄力与弹丸发射

远程敌人需要一个可配置的弹丸Prefab,我单独做了一个EnemyProjectile脚本。

射手脚本核心:

public class ShooterSnail : Enemy { public GameObject projectilePrefab; public Transform firePoint; public float fireCooldown = 2f; public float projectileSpeed = 4f; private float cooldownTimer; protected override void UpdateIdle() { if (IsPlayerInRange(detectRange)) { currentState = EnemyState.Attack; FacePlayer(); } } protected override void UpdateAttack() { FacePlayer(); cooldownTimer -= Time.deltaTime; if (cooldownTimer <= 0) { anim.SetTrigger("attack"); cooldownTimer = fireCooldown; } } public void FireProjectile() { // 动画事件调用 if (player == null) return; GameObject proj = Instantiate(projectilePrefab, firePoint.position, Quaternion.identity); Vector2 dir = (player.position - firePoint.position).normalized; proj.GetComponent<EnemyProjectile>().Init(dir, projectileSpeed, contactDamage); } public void AttackFinished() { if (IsPlayerInRange(detectRange)) currentState = EnemyState.Attack; else currentState = EnemyState.Idle; } }

弹丸脚本:

public class EnemyProjectile : MonoBehaviour { private Vector2 direction; private float speed; private int damage; private float lifeTime = 3f; public void Init(Vector2 dir, float spd, int dmg) { direction = dir.normalized; speed = spd; damage = dmg; Destroy(gameObject, lifeTime); } void Update() { transform.position += (Vector3)(direction * speed * Time.deltaTime); } private void OnTriggerEnter2D(Collider2D other) { if (other.CompareTag("Player")) { other.GetComponent<PlayerHealth>()?.TakeDamage(damage); Destroy(gameObject); } else if (other.gameObject.layer == LayerMask.NameToLayer("Ground")) { Destroy(gameObject); } } }

远程敌人最核心的是蓄力前摇。anim.SetTrigger("attack")和FireProjectile在动画事件里分两帧触发,攻击动画前60%是蓄力动作,后40%才是吐弹。这个节奏非常重要,没有前摇的远程攻击等于作弊,玩家完全没有反应时间。空洞骑士里的远程虫子吐弹前都有明显抬头动作,就是在给玩家信号。

3.6 守卫的冲撞与冷却机制

守卫敌人最简单,固定不动,玩家进入范围就冲。但冲撞如果做得太频繁会变成疯狗,完全没有节奏感,所以核心是冷却时间和冲撞预警。

public class GuardKnight : Enemy { public float chargeSpeed = 5f; public float chargeDistance = 3f; public float cooldown = 2.5f; private bool isCharging; private float chargeStartX; private float cooldownTimer; protected override void UpdateIdle() { cooldownTimer -= Time.deltaTime; if (IsPlayerInRange(detectRange) && cooldownTimer <= 0) { currentState = EnemyState.Attack; anim.SetTrigger("charge"); isCharging = true; chargeStartX = transform.position.x; } } protected override void UpdateAttack() { if (!isCharging) return; float dir = player.position.x >= transform.position.x ? 1f : -1f; rb.velocity = new Vector2(dir * chargeSpeed, rb.velocity.y); // 冲撞距离达到后停下 if (Mathf.Abs(transform.position.x - chargeStartX) >= chargeDistance) { rb.velocity = Vector2.zero; isCharging = false; cooldownTimer = cooldown; anim.SetTrigger("recover"); currentState = EnemyState.Idle; } } private void OnCollisionEnter2D(Collision2D collision) { // 冲撞撞到墙也停下 if (isCharging && collision.gameObject.layer == LayerMask.NameToLayer("Ground")) { rb.velocity = Vector2.zero; isCharging = false; cooldownTimer = cooldown; anim.SetTrigger("recover"); currentState = EnemyState.Idle; } } }

注意这里cooldownTimer在UpdateIdle里倒计时,这样守卫冲完一次不会马上再冲,给了玩家一个明确的输出窗口。做动作游戏,敌人在攻击完后的“硬直窗口”和“冷却时间”,是控制难度最核心的旋钮。

4. 制作中的细节问题

4.1 敌人阴影与剔除的坑

做这集时我遇到的最烦问题就是阴影和剔除。

先用ShadowCaster2D或Unity的URP阴影设置给敌人所在层配好阴影后,在场景里跑起来发现个别敌人刚进入摄像机视野边缘就被剔除了,代码还在跑但Game视图里看不到。查了半天是Unity渲染包围盒(MeshRenderer bounds)计算问题——敌人用了子物体上的Sprite作为显示,但Composite Collider或者父节点上没有Renderer,Unity在做视锥剔除时只认根物体上的Renderer,但根物体Bounds没被更新,导致敌人明明在屏幕边缘却提前消失了。

解决办法很粗暴但有效:

  1. 给敌人根节点添加一个空的SpriteRenderer,把Sprite设为空,然后勾选Always Update(或者是用代码在每个敌人的Awake里执行GetComponent<Renderer>().bounds = new Bounds(transform.position, new Vector3(3f,3f,0f)),手动扩大边界)。
  2. 或者更简单——把所有敌人做成Prefab时,保证根节点下第一个子物体是可见的SpriteRenderer,这样Unity会自动把子节点的渲染范围合并进父级包围盒。

这个坑非常隐蔽,你正常在编辑器里看敌人没问题,一旦发布到WebGL或者真机上,就会出现敌人“神隐”或者“突然闪现”的问题。如果你后续要把Demo发布成WebGL,就特别容易踩中——WebGL的视锥剔除逻辑和编辑器下不完全一样,编辑器不显示问题,发布后问题就开始冒头了。

4.2 碰撞体、物理材质与贴地抖动

敌人巡逻时最烦的是贴地抖动。原因是碰撞体边界和地面碰撞体边界之间有一点点空隙,重力每帧把敌人往下拉一点、碰撞又弹回来,视觉上就是高频抖动。

排查后发现是地面用的碰撞体是Tilemap Collider 2D,而敌人用的是Box Collider 2D,两者接触时物理引擎的接触偏移(Contact Offset)会导致微小抖动。

解决办法有三种,选一种就行:

  • 坑方法1:把敌人碰撞体底部稍微伸长0.02到0.05个单位,让它嵌入地面一点。我这个Demo里就把Walker_Bug的碰撞体高度从0.8调到了0.85,底部多出了0.05,抖动彻底消失。
  • 坑方法2:在Project Settings -> Physics2D里把Contact Offset改小,从默认0.01改到0.005。
  • 坑方法3:给地面Tilemap添加一个Composite Collider 2D,把碎片化碰撞合并成一个整体,减少物理引擎的连续碰撞解算次数,性能还能顺便提升。

我做的时候三个方法都试过,最推荐第三种,一劳永逸。Composite Collider 2D不光是解决抖动,对性能也友好。你一个地图上百个Tile如果每个都是一个独立碰撞体,物理引擎每一帧都要处理上百个接触点,合并后只处理几个接触点,性能差别非常大。

4.3 敌人死亡时碰撞体要关、物理要停

做死亡反馈时有个细节很容易被忽略:敌人死亡动画播放期间,它身上的Collider和Rigidbody还在参与物理计算,玩家可能被一个正在“死”的敌人碰到,太冤了。而且如果敌人有个凸出来的碰撞体,死亡时还可能把玩家顶开一下,穿模感特别强。

我的处理是在Die()里做三件事:

  1. GetComponent<Collider2D>().enabled = false:关闭碰撞,玩家可以穿过尸体。
  2. rb.velocity = Vector2.zero:清空速度,防止尸体被击飞后漂移。
  3. rb.simulated = false:停止物理模拟,敌人的刚体不再参与任何物理运算。

这样敌人死亡后就完全变成一个纯视觉物件,播放完死亡动画销毁。

另外还踩过一个坑:敌人死亡动画播放时没有播放死亡音效,后面检查发现是AudioSource挂在了一个子物体上,父物体延迟销毁时子物体的AudioSource也被连坐销毁了,音效根本没播完。解决办法是把死亡音效用AudioSource.PlayClipAtPoint播放,这个是独立的临时AudioSource,不依赖敌人物体生命周期。

4.4 碰撞体与触发器怎么选,不能全用触发器

我在做单个敌人时一直纠结敌人攻击判定到底用Trigger还是用Collision。后来统一原则:

  • 敌人身体碰玩家:用Collision(物理碰撞),因为玩家需要被实体挡住,不能有穿模感。
  • 敌人的攻击范围判定:如果是攻击动画播到某帧造成伤害,用距离判断(我上面代码里就是这么做的)或者一个临时生成的Trigger。
  • 远程弹丸:用Trigger,因为弹丸应该穿过敌人尸体、穿过地图上的其他对象,只对玩家和墙壁有反应。

为什么不能全用Trigger?如果全用Trigger,玩家和敌人之间没有物理阻挡,玩家会直接走进敌人身体里,这在横版动作游戏里观感很差——就好像主角和敌人隔空重叠了一样。空心骑士里玩家和敌人是有实体碰撞的,玩家踩着敌人头部还可以跳起来踩踏攻击。

4.5 摄像机跟随和敌人出生时机

做敌人时还顺带确认了一个点:敌人初始位置一定要在场景里摆好,然后设置一个“激活区域”,玩家靠近后敌人才启用AI。空洞骑士地图很大,但同一时间参与AI计算的敌人不会太多,因为大多数敌人都在屏幕外待机。

我的做法很简单,给敌人基类加一个初始禁用逻辑:

protected bool isActive; void OnBecameVisible() { isActive = true; } void OnBecameInvisible() { rb.velocity = Vector2.zero; isActive = false; } void Update() { if (!isActive) return; // ... 后续状态机逻辑 }

OnBecameVisible和OnBecameInvisible是Unity自带的回调,只要敌人进入任何一个摄像机视野就会触发。这样屏幕外的敌人不会浪费性能,也不会因为一直执行AI而跑到奇怪的地方。但是注意,OnBecameInvisible不能完全替代真正的“出生点激活”,因为如果两个摄像机同时存在,可能会误触。Demo里只有主摄像机,够了。

还有个小细节:如果你想做“敌人从地板里钻出来”的出场动画,不要用SetActive(false)再SetActive(true),那样会导致所有状态丢失、Awake重新调用。正确做法是让敌人enabled = false,出场动画播放完再enabled = true。

4.6 死亡掉落与素材后续扩展

空洞骑士里干掉敌人会掉钱(吉欧),但Demo里我先没有做掉落,因为要接UI和背包系统。但我预留了扩展点:在Enemy基类里加一个OnDeathEvent的UnityEvent字段,这样后续谁想挂载掉落、统计击杀数、触发机关,直接在Inspector面板里拖引用加监听就行,不用改基类代码。

public UnityEvent OnDeathEvent; protected virtual void Die() { // ...死亡逻辑 OnDeathEvent?.Invoke(); }

这个改动非常小,但后续扩展省大事了。做Demo最忌讳的就是逻辑和逻辑之间耦合太死,留一两个事件口子,后面接系统会很舒服。

5. 常见问题速查表

这些问题都是我从这一集实操里一路踩过来的,整理出来给各位少走弯路。

问题现象直接原因解决办法
敌人在屏幕边缘消失/闪现Renderer包围盒没更新,被视锥剔除提前裁掉根节点加空SpriteRenderer,或代码手动扩大Bounds
敌人巡逻时贴地抖动碰撞体接触偏移导致物理解算反复用Composite Collider 2D合并地面碰撞,或略微加长敌人碰撞体底部
追击时在索敌边界来回横跳追击脱离距离 == 索敌距离追击脱离距离设为索敌距离的1.3~1.5倍,形成滞回区间
敌人攻击动画播到一半被中断状态切换太频繁,动画事件没播完就切状态攻击动画必须用Animation Event通知完成,再用AttackFinished()切状态
敌人死亡后玩家被幽灵碰撞体撞到死亡时没关闭碰撞体Die()里关闭Collider,物理模拟置false
敌人+子物体音效没播完AudioSource挂载在销毁父物体上用AudioSource.PlayClipAtPoint播放独立音效
飞行敌人冲到屏幕外不回来追击距离没有上限用startPos限制活动范围,或者追击距离超过某值后放弃
远程弹丸穿过地图弹丸没用碰撞检测给弹丸加Rigidbody2D,设为Trigger,检测Ground层销毁
远程攻击无前摇,玩家反应不过来蓄力动作和发射动作同帧触发动画事件分两帧设置,前60%蓄力,后40%发射
多个敌人同时攻击玩家,瞬间秒杀没有做伤害冷却或玩家无敌帧玩家受伤后加0.2~0.5秒无敌帧,或者敌人攻击间加冷却

6. 这集做完后的手感测试记录

做完这集后,我自己来回打了十几遍关卡,记录了一些手感数据,给你们参考一下节奏:

  • Walker_Bug的索敌距离2.5格,玩家下劈落地时刚好可以被它索敌到。追击速度设为玩家走路速度的1.1倍,这样玩家不跑就一定会被追上,但跑起来能甩开,压力感刚好。
  • Flyer_Moth的浮动振幅0.5格,频率2赫兹,看起来最接近虫子扇翅膀的节奏。振幅太大飞行动画会明显不自然,像坐过山车。
  • Shooter_Snail的蓄力0.6秒,实测玩家在普通走路速度下能反应并躲开,但如果玩家在攻击硬直中,会被命中一次。这个设计有意为之,逼玩家在远程敌人面前要优先处理而不是顶着弹幕砍。
  • Guard_Knight的冲撞距离3格,冷却2.5秒,玩家可以在冲撞结束后冲上去输出大约1.5秒窗口,刚好够一次三连击。

数值不是拍脑袋定的,而是反复试出来的。大家在做自己的Demo时,一定不要照抄这几个数值,因为你玩家的移动速度、跳跃高度、攻击距离和我做的不一样,同样数值会带来完全不同的手感。正确思路是:拿这几个数值当初始值,然后进游戏反复打,边打边在Inspector里改,直到手感合适。

我个人在实际操作中的体会是:做敌人AI,精巧的设计远远不如稳定的框架来得重要。一个能跑、能追、能打、能死的模板敌人,比十个花里胡哨但逻辑混乱的半成品强太多。先把我这套四类敌人跑起来,然后去改它的移动数值、检测范围、动画节奏,很快你就会有自己的心得。这个内容后续还可以继续扩展:给敌人加受击反馈(闪白、击退)、加击杀特效、加死亡掉落,这些都会在下一集战斗反馈里继续做。

返回列表