Setting Up Combat Architecture In Unity

Chandler Lane
14 min readJun 14, 2021

In my last article, I went through the process of making a somewhat primitive, top-down, click to move player controller. This time we are building on top of that.

Here is a link to the first part.

Ok, so what’s next?

I think it’s time to implement some basic combat! Nothing fancy quite just yet, as we will need to do some coding, but we will get to the cool stuff soon enough.

Last time, I drew inspiration from Runescape, so I think I’ll continue the tradition for this as well. Hey, we might even get around to recreating all of the core functionality of Runescape in the future, who knows?

This is what we are going for: Click on an enemy and move over to it to start attacking according to a weapon range value and a time between attack value. Eventually we will want the enemy will have a “damage” animation as well as a blood spatter particle effect that will happen according to an Animation Event. We’ll also make sure that the player is looking at it’s target enemy when attacking and the enemy is going to have a “death” animation when it’s health reaches Zero. We also want to cancel attacking when we click to move away from the enemy and when the enemy dies, we want to make sure we aren’t attacking anymore. We’ll get to all of the fun stuff in a later article, there is a good bit of code we need to get out of the way first. We need to set up a good framework.

First, we need to take a little bit and talk about some game architecture and how we should go about doing what we are trying to do.

Starting out, we are going to want to know when we click on an enemy. You can actually do this a few ways. We could come up with a system that uses OnMouseDown(), which is a unity event, but I think it’s best to stick to RayCasting ourselves because we can re-use bits of what we already have implemented. This will come into play later as well when we talk about “Cursor Affordance”, which is just a fancy way of letting the player know what will happen when they click by having a different cursor icon depending on what you are hovering over.

So, we already have the some of the Ray Casting logic we’ll need for the combat. Why don’t we move that logic out into a different class? I think a PlayerController class will work well here. We can use the PlayerController to do the raycasting for the movement and we can use it to drive a Combat type class as well.

So visualizing this, we’ll have the Mover.cs depending on the Player controller.

And well have, what were going to call Fighter.cs, also depending on the Player controller.

Going even further with this, we can pretty much get enemy AI for free by creating an enemy controller and have it drive those components completely separately from the player. We won’t be implementing the movement or the combat for the enemies in this article, but we are laying the groundwork for when we do.

Ok, let’s head into our Mover.cs to do some refactoring. We want to go into the MoveToCursor() method and extract the movement code into its own method called MoveTo(). This new method is going to take a Vector3 parameter and this is what we will call from our different controllers.

We also want to get rid of the Input check and the MoveToCursor() call in the Mover.cs Update().

Ok now, let’s create a new Script called PlayerController.cs and put it under the Rpg.Control namespace. It will function pretty much exactly like the Mover.cs script did, except we’re not telling the NavMeshAgent where to move. Let’s go back to our Mover.cs script really quick and extract the entire method, MoveToCursor and let’s put it in our new PlayerController class. We’ll also, In Start(), cache a reference to our Mover.cs Script so we will be able to communicate when we want to move and in Update() do the same input check we did in Mover.cs.

The big difference is we are now accessing the MoveTo() function through our cached Mover Reference. Nice! We’ve separated out a movement control layer.

This is what our refactored Mover.cs script looks like.

Ok, time to test out what we have done so far. Let’s see if we can still run around the same as before.

Ok, cool! Everything is working like it should.

Next let’s make our Fighter class inside of a new “Combat” namespace. It won’t have much functionality right now, just a log statement letting us know that we are attacking. Let’s create a method called Attack() in our new script and lets just put a long statement saying “Player: Attacking”.

Let’s create one more class under the “Combat” namespace called CombatTarget.cs. This will allow us to know that we are clicking on something we are able to attack. We’ll keep this completely empty for now.

Now that we have those two scripts created, let’s go back to our PlayerController and do a bit of refactoring. Let’s clean up our Update() a little bit and be more clear about what we are doing. Let’s take that input check and put it in a method called InteractithMovement(). We can go ahead and do the same thing with combat. Let’s add in a method to Update() called InteractWithCombat() as well. Having these both in update is further laying the groundwork for that “Cursor Affordance” I was talking about earlier. More on that another time.

We’ll be reusing some of the functionality from our other raycast but not all of it. We actually need to utilize a different type of RayCast for what we are trying to achieve. With our movement Raycast, Physics.Raycast, we just want the first hit.point of the thing we clicked. The NavMeshAgent will take care of the rest. Combat is different, however. We actually want to know if we have clicked on an enemy, even if it is being obscured by something like trees, particle effects or even NPCs, we want to be able to attack it. Most of the time the player will probably not have an issue, but we want to give the Combat Targets priority. We can do that by using Physics.RayCastAll. This will return an array of hit targets that we iterate over and check if we are hitting a combat target.

First, let’s extract a method from our MoveToCursor() method. We’ll be re- using our Ray creation for our InteractWithCombat(), so let’s create a method in the PlayerController.cs called GetRay() have it be of return type Ray.

We can do an inline statement and have GetRay() as one of the parameters in the MoveToCursor() method to clean things up a bit. Also, while we are at it, let’s cache a reference to our Fighter component in Start().

Let’s hop over to our Fighter.cs script. We won’t need this now, but It was something I forgot to add in earlier. In our Attack() method, put a parameter “target” of CombatTarget type.

Now that’s out of the way, let’s go to our InteractWithCombat() and implement the RayCastAll functionality. We’ll be casting the ray and using a foreach loop to check if any of the hits are Combat Targets. If we find a match, we are going to call Attack() on the Fighter.cs only if we have pressed down the Left Mouse Button.

Ok, Time to test this out. First, we need to make sure the player has the right components and we need to put in an enemy for us to attack.

Put the new PlayerController.cs and the Fighter.cs scripts on the player and then create an empty game object called “Enemy”. We are going to restructure our prefabs in a later article, but this will do for now.

I am using a troll from the Synty Pack “Fantasy Rivals” for my enemy. Link below.

For our enemy, we need to add the CombatTarget.cs script, an Animator component, and we need to add and configure a capsule collider.

I am using the Player’s Animator controller called “Character”, but later we will create another one for the enemy as it will have different animations.

I think the Capsule Collider works best for this type of enemy, but another one may be better suited if you are using a different shaped enemy.

Now that we have all that set up, hit play and click on the enemy and you should see the log statement print out in the console.

Now that we have that working, we need to sort out our action priorities. You might notice that we are moving and attacking at the same time. We do want to move to our target on attack, but we want that to be an “attack” movement not our regular InteractWithMovement() movement. The way we can sort out these action priorities is first by having our InteractWithCombat() method return a boolean value depending on its success.

In our Update() on the player controller, we can then put that method in an if statements and return true if it successful.

This will return true while we are hovering over an enemy target. We are opening those doors for that “Cursor Affordance”. While this is returning true, we’ll be showing a combat cursor. We return false when there is no enemy and we can then move on to our movement code and interact with it if we need to.

This can be done more optimally no doubt. It’s not a good idea to have GetComponent() calls in Update(). It’s an expensive method and we are calling it basically 60 frames per second. This is more of a prototype solution. A better one would be some type of event system using OnMouseEnter() and OnMouseExit(). That way would avoid those pesky GetComponent()calls. well. We’ll keep the solution we have for now though, it serves our purpose.

Let’s test this out and make sure it’s working the way we want.

Nice! We’ve got our actions sorted out now.

Ok, we need to do a bit of refactoring. We want to do a similar thing with our InteractWithMovement() method so we can have Cursor Affordance later on, but as of now we have a conditional statement blocking us. We can move that down to where we call our mover in MoveToCursor() and then do the same thing for the Cursor affordance by returning “true” after the conditional. Also, to go further with the refactoring, we can just combine the functionality of MoveToCursor with InteractWithMovement().

Now, in Update() let’s put the InteractWithMovement() in a conditional and then lets add placeholder log statement if we can’t interact with movement. We can do some sort of “X” cursor denoting no action can be taken here later on down the line.

The next thing we want to tackle is moving within a certain range to the enemy.

Since we are doing Runescape-Esq combat, when we start attacking an enemy we are locked on until we click off. If the enemy moves, we move according to the enemy while still attacking. We can do the fighter’s movement inside of the Fighter.cs script to keep things seperate. Also we’ll need some concept of a target to keep track of.

With that in mind, let’s head into our Fighter.cs script and do some coding.

Let’s start by getting a private Transform variable called “target” and in Attack() let’s set that target qual to the CombatTarget’s transform. Also, for clarity, let’s rename the Combat Target parameter in Attack() to “combatTarget”.

So, when this is called by the PlayerController, our fighter will have a target.

Now we want to be able to move towards our target.

Let’s start by cache a reference to our Mover.cs in Start() and then in Update(), if our target isn’t null, we’ll call MoveTo() and pass in the Combat Target’s location.

Ok, let’s check it out.

So, not exactly what we want. We are stopping right in the middle of the enemy instead of at a range. We can change that.

Inside of our Fighter.cs, let’s serialize a weapon range that we can set in the inspector. Ideally this will be done through weapon configs, but that will come later.

Now, we need a way to stop our NavMeshAgent because right now it is moving continuously. There is an easy way to do this with the NavMeshAgent property “isStopped”. Let’s create a public method called Stop in the Mover.cs script. We can set isStopped equal to “true” and then when we call MoveTo() we can set it equal to “false” so we can start moving again.

Ok now to implement the Range functionality. We can use the Vector 3 method Distance() to check our distance to the target. If we’re not in range we will move towards the target, if we are in range we will be stopped.

let’s test it out!

Sweet, we are now moving into range and stopping! Also if the enemy moves, we move.

One problem though. We’re stuck in combat. We need a way to cancel our combat state so we can start moving on our own again. Right now were not differentiating between moving for combat and our regular movement.

Let’s create a method called Cancel() in our fighter that will just set the target equal to null. This cancels our combat state because the Fighter.cs’s main code only runs if there is a target set.

Now let’s head over to our Mover.cs script and make some changes. First we need to cache a reference to the Fighter component.

Let’s add a method called StartMoveAction() that takes in a Vector 3 parameter called “destination” and then let’s call the Cancel() method on the Fighter.

One more thing to do. In our player controller in the InteractWithMovement() method, Instead of calling MoveTo() we’ll call StartMoveAction() and pass in the hit.point just as before.

Ok, let’s head back to Unity and test it out!

Ok we have cancelled combat! We’ve created somewhat of a potential issue, however. Circular Dependencies. We have the Mover Referencing the Fighter and the Fighter referencing the mover.

This could create nasty problems on down the line when these classes get to be more complicated. We can avoid this by creating an action scheduler for both the Mover.cs and the Fighter.cs to rely and break the knot.

Let’s create our ActionScheduler.cs script in the core namescape and then create a method called StartAction(). Thinking about how to go about this, we need a way for both our mover script and our fighter script to have a Cancel action that we can call. We can use an Interface for this. Bother the mover and the fighter will implement a Cancel() method from what we’ll call the IAction interface.

We will use the IAction type as a parameter in our ActionScheduler’s StartAction() method.

First, let’s create the Interface in the Core namespace with a Cancel method. Interfaces don’t allow implementations, we will do that in the classes that implement this.

Now well Implement the interfaces in the Mover.cs and the Fighter.cs classes.

It’s red because we haven’t implemented the “contract” of the interface. since we are inheriting IAction, we must implement the Cancel() function. We’ll come back to this method soon.

The Figher.cs is errorless because we already have a Cancel() method implemented.

Let’s head back to the Action scheduler to implement the canceling logic.

The Mover and the Fighter will call StartAction() on the ActionScheduler which passes the IAction interface into a reference on the ActionScheduler.

If the current action is equal to the one being passed in, we won’t do anything. If the action isn’t null, denoted by the “?” operator, then we’ll cancel that action and then set our current action equal to the one being passed in.

Let’s also add a log statement showing we are canceling the action.

Now we need to go call StartAction() in the right spot.

Back in Mover.cs, cache a reference to the ActionScheduler in start.

and then down in StartMoveAction() call the StartAction() method on the ActionScheduler, passing in “this” into the parameter. We are passing in a reference to our Cancel() method.

To fulfill the interface contract on the mover, we can just change our Stop() method to Cancel().

Another thing we can do in the mover is get rid of all references to the Fighter since that will all be handled by the ActionScheduler.

We took out the Cancel() call to the Fighter in StartMoveAction() and removed the reference from Start().

One more thing to change in the Fighter.cs script. In Update where we were calling Stop() on the mover, we now need to change that to Cancel() since we refactored that.

Ok, we just need to put the ActionScheduler.cs on the player and we can test it out!

Ok, and there we go. We have set up our combat architecture. That was a lot to push through for not a lot of result, but we have even more of a base to build on now. We could go in many different directions from here.

In my next article, well go through all of the fun stuff like Attacking animations, particle effects, damage and death animations and Enemy AI.

until then,

Chandler.

--

--