前言

  • 如何用状态机和物理检测实现一个基础的2D敌人Skeleton的巡逻、追逐、攻击等AI行为。

代码结构与核心类

​ 为了实现后面防御和弹反的系统,我们首先需要有一个能攻击玩家的敌人。因为敌人与玩家存在许多的共通属性,因此将代码用继承的方式重写一下,提高代码重用性和便于拓展。

PlayerEnemyFSM

实体基类

  • 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()
{
//Debug.Log("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;

}
// Start is called before the first frame update
protected virtual void Start()
{
//stateMachine.Initialize();
}

// Update is called once per frame
protected virtual void Update()
{
stateMachine.currentState.LogicUpdate();
}

protected virtual void FixedUpdate()
{
stateMachine.currentState.PhysicsUpdate();
}
}
  • EnemyState

​ 与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()
{

}
}

设计敌人的逻辑

image-20250525095542765

  • 初始状态为Patrol状态,会朝着面朝方向前进,如果碰到墙壁或平台边缘就进入Idle状态
  • Idle状态会停顿一段时间,一段时间后转向回到巡逻状态
  • Patrol状态如果在面前或背后一段距离内检测到玩家,就进入Chase状态
  • Chase状态会以更快的速度接近玩家,如果接触到玩家就进入Attack状态,如果玩家脱离检测区域就回到Patrol状态
  • Attack状态播放完一遍攻击动画就结束

具体实现

动画状态机

image-20250525095710265

状态检测实现

Skeleton的FSM切换依赖几个状态的检测,GroundDetect前面已经实现过,WallDetect和PlayerDetect还有PlayerContacted的实现基本一样。也是添加子物体作为检测起点,使用RayCast检测指定距离和图层内的物体。

image-20250525101155190

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()
{
//entity = GetComponent<Entity>();
}

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(); //先翻转再进入Patrol
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()
{

}
}