前言
如何用状态机和物理检测实现一个基础的2D敌人Skeleton的巡逻、追逐、攻击等AI行为。
代码结构与核心类 为了实现后面防御和弹反的系统,我们首先需要有一个能攻击玩家的敌人。因为敌人与玩家存在许多的共通属性,因此将代码用继承的方式重写一下,提高代码重用性和便于拓展。
实体基类
Entity 角色(玩家、敌人)的通用基类,包含如移动、朝向、物理检测等基础属性和方法。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 public class Entity : MonoBehaviour { public Rigidbody2D rigidBody; public PlayerInput playerInput; public PhysicsCheck physicsCheck; public Animator animator; public bool isAttacking; public bool facingRight = true ; public int facingDir = 1 ; public int moveSpeed; protected virtual void Awake () { rigidBody = GetComponent<Rigidbody2D>(); physicsCheck = GetComponent<PhysicsCheck>(); Animator[] animators = GetComponentsInChildren<Animator>(); animator = animators[0 ]; } public void FlipController (float x ) { if (x > 0.05 && !facingRight) { Flip(); } else if (x < -0.05 && facingRight) { Flip(); } } public void Flip () { facingDir *= -1 ; facingRight = !facingRight; transform.Rotate(0 , 180 , 0 ); } public void SetVelocity (Vector2 inputXY ) { FlipController(inputXY.x); rigidBody.velocity = new Vector2(moveSpeed * inputXY.x * Time.deltaTime, rigidBody.velocity.y); } public void SetVelocity (float x,float y ) { FlipController(x); rigidBody.velocity = new Vector2(x,y); } public void SetZeroVelocity () { rigidBody.velocity = new Vector2(0 , 0 ); } }
敌人相关
Enemy : Entity 敌人基类,扩展了 Entity,增加了状态机、玩家引用等。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 public class Enemy : Entity { public EnemyStateMachine stateMachine; public Transform player{ get ; private set ; } protected override void Awake () { base .Awake(); stateMachine = new EnemyStateMachine(); player = GameObject.FindWithTag("Player" ).transform; } protected virtual void Start () { } protected virtual void Update () { stateMachine.currentState.LogicUpdate(); } protected virtual void FixedUpdate () { stateMachine.currentState.PhysicsUpdate(); } }
与PlayerState基本一样
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 public class EnemyState { protected EnemyStateMachine stateMachine; protected Enemy enemy; private string animBoolName; private string animTriggerName; protected Rigidbody2D rigidBody; protected Animator animator; protected PhysicsCheck physicsCheck; public bool animFinTrigger; public EnemyState (EnemyStateMachine stateMachine, Enemy enemy, string animBoolName, string animTriggerName ) { this .stateMachine = stateMachine; this .enemy = enemy; this .animBoolName = animBoolName; this .animTriggerName = animTriggerName; } public virtual void Enter () { rigidBody = enemy.rigidBody; animator = enemy.animator; physicsCheck = enemy.physicsCheck; if (!string .IsNullOrEmpty(animBoolName) && HasAnimatorParameter(animator, animBoolName, AnimatorControllerParameterType.Bool)) animator.SetBool(animBoolName, true ); if (!string .IsNullOrEmpty(animTriggerName) && HasAnimatorParameter(animator, animTriggerName, AnimatorControllerParameterType.Trigger)) animator.SetTrigger(animTriggerName); } public virtual void Exit () { if (!string .IsNullOrEmpty(animBoolName) && HasAnimatorParameter(animator, animBoolName, AnimatorControllerParameterType.Bool)) { animator.SetBool(animBoolName, false ); } } public virtual void LogicUpdate () { } public virtual void PhysicsUpdate () { } private bool HasAnimatorParameter (Animator animator, string paramName, AnimatorControllerParameterType type ) { foreach (var param in animator.parameters) { if (param.name == paramName && param.type == type) return true ; } return false ; } }
Skeleton : Enemy 具体的骷髅敌人类,拥有自己的状态实例(如 idleState、patrolState、chaseState、attackState)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 public class Skeleton : Enemy { public SkeletonIdleState idleState; public SkeletonPatrolState patrolState; public SkeletonChaseState chaseState; public SkeletonAttackState attackState; protected override void Awake () { base .Awake(); idleState = new SkeletonIdleState(stateMachine, this , "Idle" , "IdleTrigger" , this ); patrolState = new SkeletonPatrolState(stateMachine, this , "Patrol" , "PatrolTrigger" , this ); chaseState = new SkeletonChaseState(stateMachine, this , "Chase" , "ChaseTrigger" , this ); attackState = new SkeletonAttackState(stateMachine, this , "Attack" , "AttackTrigger" , this ); } protected override void FixedUpdate () { base .FixedUpdate(); } protected override void Start () { base .Start(); stateMachine.Initialize(patrolState); } protected override void Update () { base .Update(); } }
SkeletonState: EnemyState
在EnemyState基础上增加了Skeleton,拿到Skeleton特有的属性和状态
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 public class SkeletonState : EnemyState { protected Skeleton skeleton; protected float stateTimeer; public SkeletonState (EnemyStateMachine stateMachine, Enemy enemy, string animBoolName, string animTriggerName, Skeleton skeleton ) : base (stateMachine, enemy, animBoolName, animTriggerName ) { this .skeleton = skeleton; } public override void Enter () { base .Enter(); } public override void Exit () { base .Exit(); } public override void LogicUpdate () { base .LogicUpdate(); } public override void PhysicsUpdate () { } }
设计敌人的逻辑
初始状态为Patrol状态,会朝着面朝方向前进,如果碰到墙壁或平台边缘就进入Idle状态
Idle状态会停顿一段时间,一段时间后转向回到巡逻状态
Patrol状态如果在面前或背后一段距离内检测到玩家,就进入Chase状态
Chase状态会以更快的速度接近玩家,如果接触到玩家就进入Attack状态,如果玩家脱离检测区域就回到Patrol状态
Attack状态播放完一遍攻击动画就结束
具体实现 动画状态机
状态检测实现 Skeleton的FSM切换依赖几个状态的检测,GroundDetect前面已经实现过,WallDetect和PlayerDetect还有PlayerContacted的实现基本一样。也是添加子物体作为检测起点,使用RayCast检测指定距离和图层内的物体。
PhysicsCheck 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 public class PhysicsCheck : MonoBehaviour { [SerializeField ]private Entity entity; public bool isGrounded; public bool wallDetected; public Transform groundDetect; public Transform wallDetect; public Transform playerDetect; public float groundDetectDist; public float wallDetectDist; public float playerDetectDist; public float playerContactedDist; public LayerMask groundLayer; public LayerMask wallLayer; public LayerMask playerLayer; public bool playerDetected => playerDetect != null && Physics2D.Raycast( playerDetect.position, entity.facingDir * Vector2.right, playerDetectDist, playerLayer)||Physics2D.Raycast( playerDetect.position, entity.facingDir * Vector2.left, playerDetectDist, playerLayer); public bool playerContacted => playerDetect != null && Physics2D.Raycast( playerDetect.position, entity.facingDir * Vector2.right, playerContactedDist, playerLayer); void Awake () { } private void Update () { CheckGround(); WallDetected(); } private void CheckGround () { if (groundDetect == null ) return ; isGrounded = Physics2D.Raycast(groundDetect.position, Vector2.down, groundDetectDist, groundLayer); } private void WallDetected () { if (wallDetect == null ) return ; wallDetected = Physics2D.Raycast(wallDetect.position, entity.facingDir*Vector2.right, wallDetectDist, wallLayer); } private void OnDrawGizmosSelected () { Gizmos.color = Color.green; Gizmos.DrawLine(groundDetect.position, groundDetect.position + Vector3.down * groundDetectDist); Gizmos.color = Color.red; Gizmos.DrawLine(wallDetect.position, wallDetect.position + entity.facingDir * Vector3.right * wallDetectDist); Gizmos.color = Color.blue; Gizmos.DrawLine(playerDetect.position, playerDetect.position + entity.facingDir * Vector3.right * playerDetectDist); Gizmos.DrawLine(playerDetect.position, playerDetect.position + entity.facingDir * Vector3.left * playerDetectDist); Gizmos.color = Color.yellow; Gizmos.DrawLine(playerDetect.position, playerDetect.position + entity.facingDir * Vector3.right * playerContactedDist); } }
Patrol 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 public class SkeletonPatrolState : SkeletonState { public SkeletonPatrolState (EnemyStateMachine stateMachine, Enemy enemy, string animBoolName, string animTriggerName, Skeleton skeleton ) : base (stateMachine, enemy, animBoolName, animTriggerName, skeleton ) { } public override void Enter () { base .Enter(); } public override void Exit () { base .Exit(); } public override void LogicUpdate () { base .LogicUpdate(); if (physicsCheck.playerDetected) stateMachine.ChangeState(skeleton.chaseState); if (!physicsCheck.isGrounded || physicsCheck.wallDetected){ stateMachine.ChangeState(skeleton.idleState); } } public override void PhysicsUpdate () { enemy.SetVelocity(enemy.facingDir * enemy.moveSpeed * Time.deltaTime, 0 ); } }
Idle 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 public class SkeletonIdleState : SkeletonState { public SkeletonIdleState (EnemyStateMachine stateMachine, Enemy enemy, string animBoolName, string animTriggerName, Skeleton skeleton ) : base (stateMachine, enemy, animBoolName, animTriggerName, skeleton ) { } public override void Enter () { base .Enter(); stateTimeer = 2f ; } public override void Exit () { base .Exit(); } public override void LogicUpdate () { base .LogicUpdate(); if (stateTimeer <= 0 ) { enemy.Flip(); stateMachine.ChangeState(skeleton.patrolState); } else stateTimeer -= Time.deltaTime; } public override void PhysicsUpdate () { enemy.SetZeroVelocity(); } }
Chase 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 public class SkeletonChaseState : SkeletonState { public SkeletonChaseState (EnemyStateMachine stateMachine, Enemy enemy, string animBoolName, string animTriggerName, Skeleton skeleton ) : base (stateMachine, enemy, animBoolName, animTriggerName, skeleton ) { } public override void Enter () { base .Enter(); } public override void Exit () { base .Exit(); } public override void LogicUpdate () { base .LogicUpdate(); if (physicsCheck.playerContacted) { stateMachine.ChangeState(skeleton.attackState); } else if (!physicsCheck.playerDetected) { stateMachine.ChangeState(skeleton.patrolState); } } public override void PhysicsUpdate () { if (enemy.transform.position.x>= enemy.player.position.x) { enemy.SetVelocity(2 *Vector2.left * enemy.moveSpeed * Time.deltaTime); } else if (enemy.transform.position.x<enemy.player.position.x) { enemy.SetVelocity(2 *Vector2.right * enemy.moveSpeed * Time.deltaTime); } } }
Attack 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 public class SkeletonAttackState : SkeletonState { public SkeletonAttackState (EnemyStateMachine stateMachine, Enemy enemy, string animBoolName, string animTriggerName, Skeleton skeleton ) : base (stateMachine, enemy, animBoolName, animTriggerName, skeleton ) { } public override void Enter () { base .Enter(); animFinTrigger = false ; enemy.SetZeroVelocity(); } public override void Exit () { base .Exit(); } public override void LogicUpdate () { base .LogicUpdate(); if (animFinTrigger) stateMachine.ChangeState(skeleton.patrolState); } public override void PhysicsUpdate () { } }