Enemy Behaviour Using C# Delegates in Unity
In this tutorial, we will implement enemy behaviour with Finite State Machine using C# delegates in Unity. This is a demonstration of C# delegate based FSM that we created in Part 4 of the tutorial.
This tutorial first appeared on Faramira.
This is Part 5 of the tutorial. Here we will move further by demonstrating an enemy NPC behaviour, which handles multiple animation states of a 3D animated character, using the delegate based FSM created in Part 4 of the tutorial.
Configure the NPC
Download and Import the Asset
We will reuse the project and the scene from our previous tutorial, Part 3. For the enemy NPC, we will use the following free asset from Unity Asset Store.
Create the NPC’s Animation Controller
Go to the folder called Animations inside the Resources folder.
Right-click on the Project window → Create → Animation Controller. Name it NPCAnim.
The asset comes with the following animations. We will use the Idle, Walk, Run, Skill, Attack, Damage, and Death animations.
Right-click on the Animator window and create a new Blend Tree state called Movement.
Double click on Movement Blend Tree to edit.
Add two new Float parameters called PosX and PosZ.
Change the Blend Type to 2D Freeform Directional and choose the PosX and PosZ parameters.
Add the Idle, Walk and Run motions into the Blend Tree.
Add four more parameters—one Bool for Attack and three Trigger for Damage, Skill and Die.
Add the Die, Skill, Damage and Attack motions in the Animator.
Movement to Damage Transition
The movement to Damage Transition is triggered by the Damage parameter. Make sure you uncheck the Has Exit Time. Also, ensure that the Damage animation is not looping.
Movement to Attack Transition
The movement to Attack Transition is enabled by the Attack parameter set to true. Make sure you uncheck the Has Exit Time. Ensure that the Attack animation is looping by checking the Loop Time and Loop Pose checkboxes.
Movement to Skill Transition
The movement to Skill Transition is triggered by the Skill parameter. Make sure you uncheck the Has Exit Time. Ensure that the Attack animation is not looping by unchecking the Loop Time checkbox.
Damage to Movement and Skill to Movement Transitions
For Damage to Movement and Skill to Movement transitions ensure that Has Exit Time is enabled and the animations for Skill and Damage are not looping.
Attack to Movement Transition
For Attack to Movement transition disable Has Exit Time and make the transition condition set to Attack as False.
Any State to Die Transition
Any State to Die transition is triggered by the Die parameter. Make sure that the animation is not set to Has Exit Time and not looping.
Create the Enemy Game Object
Create an empty game object and name it as EnemyNPC. Set it to position 0, 0, 0. Now drag and drop the Skeleton model from Assets->Fantasy Monster->Skeleton->Character into the EnemyNPC.
You will see that the Skeleton model is tin in scale in comparison with our Player game object.
Select the Skeleton@Skin game object and scale it by 13, 13, 13.
Change the position of the EnemyNPC to 3, 0, 0.
Now, select the EnemyNPC game object and add the Character Controller component to it. Change the Center value for Y to be 1. See figure below.
Enemy NPC States
The state diagram for our enemy NPC is given below.
Let’s start with the implementation.
Select the EnemyNPC game object and add a new Script component called EnemyNPC.cs. Double click and open the file in your favourite IDE.
Copy the code from the previous tutorial (Part 4) for the EnemyNPC. In our previous tutorial, we used key presses to handle transitions. Here will remove those key pressed based transition and implement a simple AI system for the enemy NPC.
Let’s start with the data that we will require for our enemy NPC.
#region NPC data // The maximum speed at which the enemy NPC can move. public float mMaxSpeed = 3.0f; // The walking speed of the enemy NPC public float mWalkSpeed = 1.5f; // The maximum viweing distance of the enemy NPC public float mViewingDistance = 10.0f; // The maximum viewing angle of the enemy NPC. public float mViewingAngle = 60.0f; // The distance at which the enemy NPC will start attacking. public float mAttackDistance = 2.0f; // The turning rate of the enemy NPC. public float mTurnRate = 500.0f; // The tags for this NPC's enemy. Usually, it will be // the player. But for other games this enemy NPC // can not only attack the player but also other NPCs. public string[] mEnemyTags; // The gravity. public float Gravity = -30.0f; // The transform where the head is. This will // determine the view of the enemy NPC based on // it's head (where eyes are located) public Transform mEyeLookAt; // The distance to the nearest enemy of the NPC. // In this demo, we only have our player. So // this is the distance from the enemy NPC to the // Player. [HideInInspector] public float mDistanceToNearestEnemy; // The nearest enemy of the enemy NPC. // This is in case we have more than // one player in the scene. It could also // mean other NPCs that are enemy to // this NPC. [HideInInspector] public GameObject mNearestEnemy; // The reference to the animator. Animator mAnimator; // The reference to the character controller. CharacterController mCharacterController; // The total damage count. int mDamageCount = 0; // The velocity vector. private Vector3 mVelocity = new Vector3(0.0f, 0.0f, 0.0f); // The Finite State Machine. public FSM mFsm; // The maximum damage count before the // enemy NPC dies. public int mMaxNumDamages = 5; #endregion
The code above is self-explanatory. These are the various variables that will be used by the Script during its execution.
Select the EnemyNPC game object and find the Head Transform node. Drag and drop it to the mEyeLookAt variable.
This will be used to calculate the LookAt direction for the enemy NPC. Do note that the forward vector for the Head is Up rather than Forward.
So, we create a function that returns the forward (look at) direction for the EnemyNPC.
public Vector3 GetEyeForwardVector() { // The Up vector (green coloured vector) // is actually the forward vector for Head transform. return mEyeLookAt.up; }
The Start Method
void Start() { // Instead of here we could also have set as // public and then drag and drop in the editor. mAnimator = transform.GetChild(0).GetComponent<Animator>(); mCharacterController = gameObject.GetComponent<CharacterController>(); if (!mEyeLookAt) { mEyeLookAt = transform; } // The below codes are from previous tutorialPart 4. mFsm = new FSM(); mFsm.Add((int)StateTypes.IDLE, new NPCState(mFsm, StateTypes.IDLE, this)); mFsm.Add((int)StateTypes.CHASE, new NPCState(mFsm, StateTypes.CHASE, this)); mFsm.Add((int)StateTypes.ATTACK, new NPCState(mFsm, StateTypes.ATTACK, this)); mFsm.Add((int)StateTypes.DAMAGE, new NPCState(mFsm, StateTypes.DAMAGE, this)); mFsm.Add((int)StateTypes.DIE, new NPCState(mFsm, StateTypes.DIE, this)); Init_IdleState(); Init_AttackState(); Init_DieState(); Init_DamageState(); Init_ChaseState(); mFsm.SetCurrentState(mFsm.GetState((int)StateTypes.IDLE)); }
In the Start method, we initialize the mAnimator and mCharacterController variables. We then proceed with initializing the Finite State Machine as described in our previous tutorial.
There are no changes to the Update and FixedUpdate methods. Our FSM will do most of the work. So, the Update and FixedUpdate methods remain the same for the Script.
void Update() { mFsm.Update(); } void FixedUpdate() { mFsm.FixedUpdate(); }
The Enemy Tag for our Enemy NPC
We will now set the values for mEnemyTags. This will take all the tag names of game objects that are this EnemyNPC’s enemy. For us, it will be the main player.
So, first of all, we set the tag for our Player to be Player.
Then we select the EnemyNPC and set the value of the mEnemyTags, as shown below. For our tutorial, we only have our player as the enemy of our enemy NPC. So, the size of the mEnemyTags will be 1, and the value will be Player.
Move Method
We will now implement the Move method. This is for our NPC to be able to move.
public virtual void Move(float speed) { Vector3 forward = transform.TransformDirection(Vector3.forward).normalized; mAnimator.SetFloat("PosZ", speed / mMaxSpeed); mVelocity = forward * speed; mVelocity.y += Gravity * Time.deltaTime; mCharacterController.Move(mVelocity * Time.deltaTime); if (mCharacterController.isGrounded && mVelocity.y < 0) mVelocity.y = 0f; }
This is a simple movement towards the EnemyNPC’s forward direction.
MoveTowards Method
MoveTowards method allows the NPC to move towards a certain point at a predefined speed.
public bool MoveTowards(Vector3 tpos, float speed) { float dist = Distance(gameObject, tpos); if (dist > 1.5f) { Vector3 mpos = transform.position; Vector3 currentDirection = transform.forward; Vector3 desiredDirection = (tpos - mpos).normalized; Vector3 forward = Vector3.Scale(desiredDirection, new Vector3(1, 0, 1)).normalized; transform.rotation = Quaternion.RotateTowards(transform.rotation, Quaternion.LookRotation(forward), mTurnRate * Time.deltaTime); Move(speed); return true; // still moving } return false; //complete }
GetNearestEnemyInSight Method
This method finds the nearest enemy for the NPC. In our case, it will find the nearest player. There is only one player, so it will always be the same game object. However, in a multiplayer game or in a game where the NPC has multiple enemy tags, then there could be many enemy game objects for the NPC at the same time. In that case, the following method will return the nearest enemy.
It also uses the parameter called useVieweingAngle, which, when used, will use the Head’s direction to determine if the EnemyNPC can see the player.
public GameObject GetNearestEnemyInSight(out float distance, float viewableDistance, bool useVieweingAngle = false) { distance = viewableDistance; GameObject nearest = null; for (int t = 0; t < mEnemyTags.Length; ++t) { GameObject[] gos = GameObject.FindGameObjectsWithTag(mEnemyTags[t]); for (int i = 0; i < gos.Length; ++i) { GameObject player = gos[i]; Vector3 diff = player.transform.position - transform.position; float curDistance = diff.magnitude; if (curDistance < distance) { diff.y = 0.0f; if (useVieweingAngle) { float angleH = Vector3.Angle(diff, GetEyeForwardVector()); if (angleH <= mViewingAngle) { distance = curDistance; nearest = player; } } else { distance = curDistance; nearest = player; } } } } return nearest; }
At the same time, we also implement a helper method that determines the distance between a game object and a point.
public static float Distance(GameObject obj, Vector3 pos) { return (obj.transform.position - pos).magnitude; }
PlayAnimation and StopAnimation Methods
We now implement two more helper methods that will enable/disable animation sequences. These two methods are PlayAnimation and StopAnimation. These two methods set the transition for a certain animation to be played or stopped.
public void PlayAnimation(StateTypes type) { switch (type) { case StateTypes.ATTACK: { mAnimator.SetBool("Attack", true); break; } case StateTypes.DIE: { mAnimator.SetTrigger("Die"); break; } case StateTypes.DAMAGE: { mAnimator.SetTrigger("Damage"); break; } } } public void StopAnimation(StateTypes type) { switch (type) { case StateTypes.ATTACK: { mAnimator.SetBool("Attack", false); break; } case StateTypes.DIE: { // trigger so no need to do anything. break; } case StateTypes.DAMAGE: { // trigger so no need to do anything. break; } case StateTypes.IDLE: { break; } case StateTypes.CHASE: { mAnimator.SetFloat("PosZ", 0.0f); mAnimator.SetFloat("PosX", 0.0f); break; } } }
We are now ready to implement the Finite State Machine state initialization methods that will allow us to implement the behaviour of our EnemyNPC.
Init_IdleState Method
For the implementation of the IDLE state, we will remove or comment on the codes we wrote for keypress enabled transitions and write new codes for changing animations.
void Init_IdleState() { NPCState state = (NPCState)mFsm.GetState((int)StateTypes.IDLE); // Add a text message to the OnEnter and OnExit delegates. state.OnEnterDelegate += delegate () { Debug.Log("OnEnter - IDLE"); }; state.OnExitDelegate += delegate () { StopAnimation(StateTypes.IDLE); Debug.Log("OnExit - IDLE"); }; state.OnUpdateDelegate += delegate () { ////Debug.Log("OnUpdate - IDLE"); //if(Input.GetKeyDown("c")) //{ // SetState(StateTypes.CHASE); //} //else if(Input.GetKeyDown("d")) //{ // SetState(StateTypes.DAMAGE); //} //else if (Input.GetKeyDown("a")) //{ // SetState(StateTypes.ATTACK); //} mNearestEnemy = GetNearestEnemyInSight(out mDistanceToNearestEnemy, mViewingDistance); if (mNearestEnemy) { if (mDistanceToNearestEnemy > mAttackDistance) { SetState(StateTypes.CHASE); return; } SetState(StateTypes.ATTACK); return; } PlayAnimation(StateTypes.IDLE); }; }
As for the logic, we find the nearest enemy (in our case the player) if within range. If the nearest enemy is valid, we check if the distance is greater than the attack distance. If so, then we make the NPC chase the player. This is done by setting the state to CHASE. If the distance is lesser than the attack distance, then we set the state to be ATTACK.
Init_AttackState Method
void Init_AttackState() { NPCState state = (NPCState)mFsm.GetState((int)StateTypes.ATTACK); // Add a text message to the OnEnter and OnExit delegates. state.OnEnterDelegate += delegate () { Debug.Log("OnEnter - ATTACK"); }; state.OnExitDelegate += delegate () { Debug.Log("OnExit - ATTACK"); StopAnimation(StateTypes.ATTACK); }; state.OnUpdateDelegate += delegate () { ////Debug.Log("OnUpdate - ATTACK"); //if (Input.GetKeyDown("c")) //{ // SetState(StateTypes.CHASE); //} //else if (Input.GetKeyDown("d")) //{ // SetState(StateTypes.DAMAGE); //} mNearestEnemy = GetNearestEnemyInSight(out mDistanceToNearestEnemy, mViewingDistance); if (mNearestEnemy) { if (IsAlive()) { if (mDistanceToNearestEnemy < mAttackDistance) { PlayAnimation(StateTypes.ATTACK); } else if (mDistanceToNearestEnemy > mAttackDistance && mDistanceToNearestEnemy < mViewingDistance) { SetState(StateTypes.CHASE); } } else { SetState(StateTypes.IDLE); } return; } if (!mNearestEnemy || mDistanceToNearestEnemy > mViewingDistance) { SetState(StateTypes.IDLE); return; } }; }
Init_ChaseState Method
void Init_ChaseState() { NPCState state = (NPCState)mFsm.GetState((int)StateTypes.CHASE); // Add a text message to the OnEnter and OnExit delegates. state.OnEnterDelegate += delegate () { Debug.Log("OnEnter - CHASE"); }; state.OnExitDelegate += delegate () { Debug.Log("OnExit - CHASE"); StopAnimation(StateTypes.CHASE); }; state.OnUpdateDelegate += delegate () { ////Debug.Log("OnUpdate - CHASE"); //if (Input.GetKeyDown("i")) //{ // SetState(StateTypes.IDLE); //} //else if (Input.GetKeyDown("d")) //{ // SetState(StateTypes.DAMAGE); //} //else if (Input.GetKeyDown("a")) //{ // SetState(StateTypes.ATTACK); //} mNearestEnemy = GetNearestEnemyInSight(out mDistanceToNearestEnemy, mViewingDistance); if (!mNearestEnemy/* || !isMoving*/) { SetState(StateTypes.IDLE); return; } if (mDistanceToNearestEnemy < mAttackDistance) { SetState(StateTypes.ATTACK); return; } MoveTowards(mNearestEnemy.transform.position, mWalkSpeed); PlayAnimation(StateTypes.CHASE); }; }
Init_DamageState Method
void Init_DamageState() { NPCState state = (NPCState)mFsm.GetState((int)StateTypes.DAMAGE); // Add a text message to the OnEnter and OnExit delegates. state.OnEnterDelegate += delegate () { mDamageCount++; Debug.Log("OnEnter - DAMAGE, Total damage taken: " + mDamageCount); }; state.OnExitDelegate += delegate () { Debug.Log("OnExit - DAMAGE"); }; state.OnUpdateDelegate += delegate () { ////Debug.Log("OnUpdate - DAMAGE"); if (mDamageCount == mMaxNumDamages) { SetState(StateTypes.DIE); return; } //if (Input.GetKeyDown("i")) //{ // SetState(StateTypes.IDLE); //} //else if (Input.GetKeyDown("c")) //{ // SetState(StateTypes.CHASE); //} //else if (Input.GetKeyDown("a")) //{ // SetState(StateTypes.ATTACK); //} PlayAnimation(StateTypes.DAMAGE); SetState(StateTypes.IDLE); }; }
Init_DieState Method
void Init_DieState() { NPCState state = (NPCState)mFsm.GetState((int)StateTypes.DIE); // Add a text message to the OnEnter and OnExit delegates. state.OnEnterDelegate += delegate () { Debug.Log("OnEnter - DIE"); }; state.OnExitDelegate += delegate () { Debug.Log("OnExit - DIE"); }; state.OnUpdateDelegate += delegate () { //Debug.Log("OnUpdate - DIE"); PlayAnimation(StateTypes.DIE); }; }
We cannot test the DIE and DAMAGE states yet because we are not shooting the enemy.
To test out, set the Player position to be at 20,0,20 and click play. See below the video!
Note that we cannot trigger the DAMAGE and subsequently the DIE states. This is because we did not implement the Player to shoot the enemy.