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); GetComponentCollider2D().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 GetComponentRigidbody2D(); anim GetComponentAnimator(); sprite GetComponentSpriteRenderer(); } 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); GetComponentCollider2D().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.GetComponentPlayerHealth()?.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播放到那一帧就会调用同名方法。具体配置路径选中敌人的Animator Controller找到攻击状态AttackState。打开Animation窗口选中攻击动画片段。在时间轴大约播放到50%位置每个人动画不一样卡在攻击动作最“打出去”的那一帧右键添加Animation Event。函数名填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.GetComponentPlayerHealth()?.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.GetComponentEnemyProjectile().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.GetComponentPlayerHealth()?.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或者父节点上没有RendererUnity在做视锥剔除时只认根物体上的Renderer但根物体Bounds没被更新导致敌人明明在屏幕边缘却提前消失了。解决办法很粗暴但有效给敌人根节点添加一个空的SpriteRenderer把Sprite设为空然后勾选Always Update或者是用代码在每个敌人的Awake里执行GetComponentRenderer().bounds new Bounds(transform.position, new Vector3(3f,3f,0f))手动扩大边界。或者更简单——把所有敌人做成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()里做三件事GetComponentCollider2D().enabled false关闭碰撞玩家可以穿过尸体。rb.velocity Vector2.zero清空速度防止尸体被击飞后漂移。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精巧的设计远远不如稳定的框架来得重要。一个能跑、能追、能打、能死的模板敌人比十个花里胡哨但逻辑混乱的半成品强太多。先把我这套四类敌人跑起来然后去改它的移动数值、检测范围、动画节奏很快你就会有自己的心得。这个内容后续还可以继续扩展给敌人加受击反馈闪白、击退、加击杀特效、加死亡掉落这些都会在下一集战斗反馈里继续做。
返回列表