前言
欢迎来到【制作100个Unity游戏】系列!本系列将引导您一步步学习如何使用Unity开发各种类型的游戏。在这第26篇中,我们将探索如何用unity制作一个unity2d横版卷轴动作类游戏,我会附带项目源码,以便你更好理解它。
本节主要是完善敌人AI,动画,有限状态机控制敌人状态切换,制作多个敌人
敌人
动画配置
撞墙判断
修改PhysicsCheck ,地面检测和撞墙判断
public class PhysicsCheck : MonoBehaviour { public Vector2 bottomOffset;// 检测圆形底部偏移量 public float checkRadius; // 圆形检测半径 public LayerMask groundLayer; // 地面图层 public bool isGround; // 是否在地面上 private Vector2 leftOffset; private Vector2 rightOffset; public bool touchLeftWall;//是否接触到左墙壁 public bool touchRightWall;//是否接触到右墙壁 private CapsuleCollider2D coll; public bool manual; //是否手动配置 private void Awake() { coll = GetComponent<CapsuleCollider2D>(); //如果不是手动配置偏移量,则根据 Collider 的位置和大小计算左右偏移量 if(!manual){ Vector2 collPos = coll.offset * (Vector2)transform.localScale; rightOffset = new Vector2(collPos.x + coll.size.x / 2 + checkRadius, coll.size.y / 2); leftOffset = new Vector2(collPos.x - coll.size.x / 2 - checkRadius, coll.size.y / 2); } } private void Update() { //根据物体的 x 轴缩放来更新偏移量 Check(); } private void FixedUpdate() { UpdateOffset(transform.localScale.x); } private void UpdateOffset(float facedir){ // 根据物体的 x 轴缩放更新左右偏移量 if(!manual){ Vector2 collPos = coll.offset * facedir; rightOffset = new Vector2(collPos.x + coll.size.x / 2 + checkRadius, coll.size.y / 2); leftOffset = new Vector2(collPos.x - coll.size.x / 2 - checkRadius, coll.size.y / 2); }else{ leftOffset = leftOffset * facedir; rightOffset = rightOffset * facedir; } } public void Check() { // 检测是否在地面上 isGround = Physics2D.OverlapCircle(transform.position + bottomOffset, checkRadius, groundLayer); //墙壁判断 touchLeftWall = Physics2D.OverlapCircle((Vector2)transform.position + leftOffset, checkRadius, groundLayer); touchRightWall = Physics2D.OverlapCircle((Vector2)transform.position + rightOffset, checkRadius, groundLayer); } private void OnDrawGizmosSelected() { // 在 Scene 视图中绘制检测范围 Gizmos.DrawWireSphere((Vector2)transform.position + bottomOffset, checkRadius); Gizmos.DrawWireSphere((Vector2)transform.position + leftOffset, checkRadius); Gizmos.DrawWireSphere((Vector2)transform.position + rightOffset, checkRadius); } }
配置
运行效果,程序会自动找到碰撞体的位置
前面设置了地面碰撞体复合,只有外框才是碰撞区域,如果敌人快速通过可能检测不到墙壁。可以修改为瓦片地图碰撞几何类型为Polygons,将瓦片地图全部做为一个碰撞体整体。
敌人基本AI逻辑实现
新增Enemy代码 ,控制敌人基本移动和动画,碰壁等待一段时间,再回头
public class Enemy : MonoBehaviour { Rigidbody2D rb; protected Animator anim; private PhysicsCheck physicsCheck; [Header("基本参数")] public float normalSpeed; // 常规速度 public float chaseSpeed; // 追逐速度 public float currentSpeed; // 当前速度 public Vector3 faceDir; // 面向方向 public bool wait;//是否等待 public float waitTime;//等待时长 private void Awake() { rb = GetComponent<Rigidbody2D>(); anim = GetComponent<Animator>(); physicsCheck = GetComponent<PhysicsCheck>(); currentSpeed = normalSpeed; } private void Update() { //面向方向 默认右边为正方向 faceDir = new Vector3(-transform.localScale.x, 0, 0); //按敌人面向和撞墙 切换敌人状态 if((physicsCheck.touchLeftWall && faceDir.x < 0 || physicsCheck.touchRightWall && faceDir.x > 0) && !wait){ wait = true; // 设为等待状态 anim.SetBool("walk", false);//禁止走路动画 StartCoroutine(WaitTimer()); // 启动等待计时 } } private void FixedUpdate() { if(!wait) Move(); } //移动方法 public virtual void Move() { anim.SetBool("walk", true);//播放走路动画 rb.velocity = new Vector2(currentSpeed * faceDir.x * Time.deltaTime, rb.velocity.y); } //等待计时携程 private IEnumerator WaitTimer() { yield return new WaitForSeconds(waitTime); // 等待时间 transform.localScale = new Vector3(faceDir.x, transform.localScale.y, transform.localScale.z);//转向 wait = false; // 取消等待状态 } }
配置
效果
野猪受伤死亡
切割图片,图片没有死亡动画,这里可以选择使用和受伤一样的动画
修改死亡动画多一个渐变消失
受伤和完整播放完受伤动画退出
死亡
修改Enemy,实现受伤面向玩家,
public bool isHurt;//是否受伤 public float hurtForce;//击退力 public float waitHitTime = 0.5f;//受伤时长 private void FixedUpdate() { if(!wait && !isHurt) Move(); } //受伤 public void OnTakeDamage(Transform attackTrans){ // attacker = attackTrans; isHurt = true; anim.SetTrigger("hit"); //转身面向攻击者 if (attackTrans.position.x - transform.position.x > 0) transform.localScale = new Vector3(-Mathf.Abs(transform.localScale.x),transform.localScale.y,transform.localScale.z); if (attackTrans.position.x - transform.position.x < 0) transform.localScale = new Vector3(Mathf.Abs(transform.localScale.x),transform.localScale.y,transform.localScale.z); //受伤被击退 Vector2 dir = new Vector2(transform.position.x - attackTrans.position.x, 0).normalized; rb.AddForce(dir * hurtForce, ForceMode2D.Impulse); //等待切换回正常状态 StartCoroutine(OnWaitHit()); } //等待切换回正常状态 private IEnumerator OnWaitHit() { yield return new WaitForSeconds(waitHitTime); isHurt = false; }
修改配置
配置受伤事件
效果
死亡
修改Enemy
public bool isDead;//是否死亡 private void FixedUpdate() { if(!wait && !isHurt && !isDead) Move(); } //死亡 public void OnDead(){ isDead = true; anim.SetBool("isDead", true); //销毁 StartCoroutine(OnDeadDestroy()); } private IEnumerator OnDeadDestroy() { yield return new WaitForSeconds(1f); Destroy(gameObject); }
配置
效果
敌人死亡时,还是会对人物产生伤害
取消玩家和Ignore Raycast的碰撞
修改Enemy,死亡修改图层
//死亡 public void OnDead(){ gameObject.layer = 2;//修改图层,避免敌人死亡时,还是会对人物产生伤害 isDead = true; anim.SetBool("isDead", true); //销毁 StartCoroutine(OnDeadDestroy()); }
效果
有限状态机&抽象类多态 定义不同状态的敌人行为
新增一个抽象基类,定义了所有状态类的基本结构。包括进入状态、逻辑更新、物理更新和退出状态等方法。
public abstract class BaseState { protected Enemy currentEnemy; // 当前敌人 public abstract void OnEnter(Enemy enemy); // 进入状态时的方法 public abstract void LogicUpdate(); // 逻辑更新方法 public abstract void PhysicsUpdate(); // 物理更新方法 public abstract void OnExit(); // 退出状态时的方法 }
新增BoarPatrolState,这是野猪的巡逻状态类,继承自BaseState类,并实现了具体的状态行为。
- 在OnEnter方法中,初始化当前敌人(野猪)对象。
- LogicUpdate方法中,根据敌人面朝方向和是否撞墙来切换敌人状态。
- PhysicsUpdate方法中,执行物理更新的逻辑。
- OnExit方法中,处理退出状态时的逻辑。
public class BoarPatrolState : BaseState { public override void OnEnter(Enemy enemy) { currentEnemy = enemy; } public override void LogicUpdate() { //按敌人面向和撞墙 切换敌人状态 if (currentEnemy.physicsCheck.touchLeftWall && currentEnemy.faceDir.x < 0 || currentEnemy.physicsCheck.touchRightWall && currentEnemy.faceDir.x > 0) { currentEnemy.wait = true; // 设为等待状态 currentEnemy.anim.SetBool("walk", false);//禁止走路动画 } } public override void PhysicsUpdate() { } public override void OnExit() { currentEnemy.anim.SetBool("walk", false); } }
修改Enemy类,作为所有敌人的父类,这个类表示敌人的基本行为,包括移动、受伤、死亡等。使用了状态机模式来管理敌人的状态,包括巡逻状态和追逐状态。在Update和FixedUpdate方法中,通过当前状态对象来执行逻辑更新和物理更新。
public bool isWaitTimer;//是否开始等待计时 [Header("状态机")] private BaseState currentState;// 当前状态 protected BaseState patrolState;// 巡逻状态 protected BaseState ChaseState;// 追逐状态 //... private void OnEnable() { currentState = patrolState; currentState.OnEnter(this); } private void Update() { //面向方向 默认右边为正方向 faceDir = new Vector3(-transform.localScale.x, 0, 0); currentState.LogicUpdate(); } private void FixedUpdate() { if (!wait && !isHurt && !isDead) Move(); currentState.PhysicsUpdate(); if (wait && !isWaitTimer) { isWaitTimer = true; StartCoroutine(WaitTimer()); // 启动等待计时 } } private void OnDisable() { currentState.OnExit(); } //...
新增Boar,继承自Enemy类,表示野猪这种特定类型的敌人。在Awake方法中初始化了野猪的巡逻状态。
public class Boar : Enemy { protected override void Awake() { base.Awake(); patrolState = new BoarPatrolState();// 设置野猪的巡逻状态 } }
野猪敌人,重新挂载Boar脚本,而不是之前的Enemy ,记得Character的受伤死亡事件也要重新配置
运行,看看程序是否能跑通
防止野猪在悬崖掉下去
修改BoarPatrolState,我们直接用脚底地面检测来判断,野猪前方没有地面(即是悬崖),等待回头即可
public override void LogicUpdate() { //按敌人面向和撞墙切换敌人状态 if ( !currentEnemy.physicsCheck.isGround || currentEnemy.physicsCheck.touchLeftWall && currentEnemy.faceDir.x < 0 || currentEnemy.physicsCheck.touchRightWall && currentEnemy.faceDir.x > 0 ) { currentEnemy.wait = true; // 设为等待状态 currentEnemy.anim.SetBool("walk", false);//禁止走路动画 } else { currentEnemy.wait = false; currentEnemy.anim.SetBool("walk", true);//播放走路动画 } }
修改PhysicsCheck,也就是地面检测多一个* transform.localScale.x,确保敌人转向时,地面检点也跟着偏过去
记得修改检测偏移到野猪头的前面位置
效果
野猪的追击状态的转换
敌人主动查找玩家
修改Enemy,原理就是向前发射一个方块检测玩家
[Header("主动发现玩家检测")] public Vector2 centerOffset;//检测框的中心偏移量 public Vector2 checkSize;//检测框的尺寸 public float checkDistance;//检测的距离 public LayerMask attackLayer;//检测图层 //发现玩家 public bool FoundPlayer() { return Physics2D.BoxCast(transform.position + (Vector3)centerOffset, checkSize, 0, faceDir, checkDistance, attackLayer); } //在场景显示检查距离 private void OnDrawGizmosSelected() { Gizmos.color = Color.red; // 设置绘制颜色为黄色,你可以根据需要选择其他颜色 Gizmos.DrawWireCube(transform.position + (Vector3)centerOffset + new Vector3(-transform.localScale.x * checkDistance, 0, 0), checkSize); // 绘制一个边框的立方体表示检测区域 }
配置
追击状态
前面我们已经创建了野猪的巡逻状态脚本BoarPatrolState,我们同理再创建一个追击状态脚本即可
新增BoarChaseState,定义野猪追击状态
public class BoarChaseState : BaseState { public override void OnEnter(Enemy enemy) { currentEnemy = enemy; } public override void LogicUpdate() { } public override void PhysicsUpdate() { } public override void OnExit() { } }
修改Boar ,赋值野猪追击状态
public class Boar : Enemy { protected override void Awake() { base.Awake(); patrolState = new BoarPatrolState();// 设置野猪的巡逻状态 chaseState = new BoarChaseState();// 设置野猪的追击状态 } }
新增枚举,定义敌人不同的状态
public enum EnemyState { Patrol, Chase, Skill }
修改Enemy,定义切换敌人状态,方法
//切换敌人状态 public void SwitchState(EnemyState state) { var newState = state switch { EnemyState.Patrol => patrolState, EnemyState.Chase => chaseState, _ => null }; currentState.OnExit();//退出上一个状态 currentState = newState; //赋值新状态 currentState.OnEnter(this);//开始新的状态 }
修改BoarPatrolState,发现玩家切换野猪为追击状态
public override void LogicUpdate() { if(currentEnemy.FoundPlayer()){ Debug.Log("发现玩家"); currentEnemy.SwitchState(EnemyState.Chase); } //... }
效果
完善追击状态脚本
追击状态 修改速度 播放奔跑动画 敌人碰壁直接转向不等待
修改BoarChaseState
public class BoarChaseState : BaseState { public override void OnEnter(Enemy enemy) { currentEnemy = enemy; currentEnemy.currentSpeed = currentEnemy.chaseSpeed;//追击速度 currentEnemy.anim.SetBool("run", true);//奔跑动画 } public override void LogicUpdate() { // 如果超过等待时间,切换为默认巡逻状态 if (currentEnemy.timeSincePlayerLost >= currentEnemy.maxTimeWithoutPlayer) { currentEnemy.SwitchState(EnemyState.Patrol); } //按敌人 是否在悬崖边 面向和撞墙 切换敌人状态 if ( !currentEnemy.physicsCheck.isGround || currentEnemy.physicsCheck.touchLeftWall && currentEnemy.faceDir.x < 0 || currentEnemy.physicsCheck.touchRightWall && currentEnemy.faceDir.x > 0 ) { currentEnemy.transform.localScale = new Vector3(currentEnemy.faceDir.x, currentEnemy.transform.localScale.y, currentEnemy.transform.localScale.z);//转向 } } public override void PhysicsUpdate() { } public override void OnExit() { currentEnemy.anim.SetBool("run", false); } }
野猪丢失目标,一段时间后回到默认状态
修改Enemy
[Header("丢失目标计时器参数")] public float lostTimeCounter = 0f;//计时器 public float lostTime = 2f; // 丢失目标时间 private void FixedUpdate() { //... //计时器 Timer(); } //追击计时器 private void Timer() { // 如果发现玩家,则重置计时器 if (FoundPlayer()) { lostTimeCounter = 0f; } else { if (lostTimeCounter >= lostTime) { lostTimeCounter = lostTime; } else { lostTimeCounter += Time.deltaTime; } } }
修改BoarChaseState
public override void LogicUpdate() { // 如果超过等待时间,切换为默认巡逻状态 if (currentEnemy.lostTimeCounter >= currentEnemy.lostTime) { currentEnemy.SwitchState(EnemyState.Patrol); } //... }
修改BoarPatrolState,速度改回默认速度
public override void OnEnter(Enemy enemy) { currentEnemy = enemy; currentEnemy.currentSpeed = currentEnemy.normalSpeed; }
配置丢失目标时间
效果
野猪朝我们冲锋时,正面受到攻击 无法击退 背面受到攻击又会击退很远
因为冲锋的力也有一个向前的力
修改Enemy ,击退前先把敌人x轴的力停下来
//受伤 public void OnTakeDamage(Transform attackTrans) { // 。。。 //受伤被击退 Vector2 dir = new Vector2(transform.position.x - attackTrans.position.x, 0).normalized; rb.velocity = new Vector2(0, rb.velocity.y);//先取消刚体x轴的力 rb.AddForce(dir * hurtForce, ForceMode2D.Impulse); //等待切换回正常状态 StartCoroutine(OnWaitHit()); }
效果
制作多个敌人
制作多个敌人可以参考前面的方法,继承Enemy,重新定义各种状态即可,比如巡逻状态,追击状态
源码
源码不出意外的话我会放在最后一节