Hi everyone,
This is the fifth article in our series “Tips for writing cleaner code that scales in Unity”. Please be sure to read the four previous posts listed here:
Article 1: Adding an AI-driven NPC
Article 2: Adding a jump mechanic using the state pattern
Article 3: Adding a modular interaction system using interfaces
Article 4: Composition and inheritance
We teamed up with Peter from Sunny Valley Studio, who has created a long list of great YouTube tutorials and courses on the topic. Peter is the main author of the articles and created a project you can follow along with.
The goal with this series is to help you write clean, scalable, and maintainable code in Unity by applying object-oriented programming principles and design patterns. Through a practical example project, where we expand a simple third-person controller, you’ll learn how to implement new features in a way that keeps your codebase organized and easy to extend.
In this fifth article of our cleaner code series, we’ll step through adding a damage system.
This article delves further into the power of interfaces, and builds on top of our previous articles – namely article 3 – by exploring how interfaces enable polymorphism, allowing us to create systems that are easily extendable with new behaviors.
Interfaces can sometimes be intimidating for beginners who don’t have a good understanding of object-oriented programming (OOP). In Unity, interfaces can be tricky to work with because dependencies are usually assigned through the Inspector, which doesn’t natively support interfaces.
In this article we will try to demystify the concept by using them to create a damage system for our game that leverages the existing Attack mechanic to affect objects in the world.
The Enemy NPC attacks the Player, triggering hit effects using the new Damage System. The Player retaliates, initiating the EnemyDeathState.
Let’s dive in and see how we can implement this into our project!
You can follow along with this article by downloading a starter project from Github. In the Project tab go to the _Scripts > Article 5 > Start folder and open the scene there.
Interfaces in object-oriented programming
In object-oriented programming (OOP), objects are often best understood by their behavior (methods) rather than just the data (fields) they hold. That is because methods define how an object interacts with other objects, making behavior the primary focus. The principle of encapsulation emphasizes bundling data with the methods that operate on it. By clearly defining the behavior an object exposes, such as through an interface, we ensure consistent and effective interactions between objects.
Example: Connecting AttackState with a Health component
Consider the following simplified example of how our AttackState
can interact with an example Health
component:
public class Health : MonoBehaviour
{
private int m_currentHealth = 2;
…
public void GetHit(int damageAmount)
{
m_currentHealth -= damageAmount;
if(m_currentHealth <= 0)
Debug.Log(“Health dropped to 0!”);
}
}
public class AttackState : State
{
private GameObject m_target;
private int m_damageAmount = 1;
…
public override void Enter()
{
m_target.GetComponent<Health>().GetHit(m_damageAmount)
}
}
In the AttackState
class, it searches for a Health
component on the target and calls the GetHit()
method on it. The AttackState
is solely interested in the interface that the Health
component exposes to interact with it, without needing to know how the Health object operates internally.
Extending functionality with an interface
Now, suppose we want to introduce a WoodenCrate into our game that should break when hit. We could modify the Health object to trigger an event for this new functionality. However, a more flexible approach is to expose the GetHit()
method as an interface.
AttackState
depends on an IDamagable
interface, represented as a dashed line with an open arrow in the diagram. Health
and WoodenCrate
classes implement the interface, allowing AttackState
to use polymorphism to call GetHit()
on any object that implements the interface.
We have introduced an interface-based solution where we define a common interface IDamagable
that exposes a GetHit(int damageAmount)
method that takes in an int parameter (we will create a similar interface in our example project). Any class implementing this interface must define the GetHit()
method. This approach allows us to treat all implementing objects as IDamageable
types, enabling consistent and reusable interaction with different objects.
public interface IDamagable
{
void GetHit(int damageAmount);
}
public class Health : MonoBehaviour, IDamagable
{
…
public void GetHit(int damageAmount) {...}
Public void AddHealth(int healthAmount) {...}
}
public class WoodenCrate : MonoBehaviour, IDamagable
{
…
public void GetHit(int damageAmount) {...}
}
public class AttackState : State
{
private GameObject m_target;
private int m_damageAmount = 1;
…
public override void Enter()
{
m_target.GetComponent<IDamagable>().GetHit(m_damageAmount)
}
}
With this setup, the AttackState
script searches for IDamagable components on the m_target GameObject. Since both Health
and WoodenCrate
implement this interface, they can be accessed by AttackState
without requiring any modifications to the AttackState
class. The AttackState
class only depends on the IDamagable
interface, which means it can interact with any object that implements the GetHit()
method, whether it’s a WoodenCrate, a Health component, or any new object we introduce to the game.
The Benefits of interfaces and polymorphism
While C# does not allow a class to inherit from more than one base class, you can implement multiple interfaces. In that way you can achieve a similar effect for defining shared contracts or behaviors.
For example, the Health and WoodenCrate objects can both inherit from MonoBehaviour and implement any number of interfaces. This capability is valuable for keeping our classes focused on a single responsibility, which in turn promotes the composition of more complex behaviors. We explored this in greater detail in Article 4.
One key aspect of using interfaces is the flexibility they provide in designing your systems. However, deciding when to implement interfaces might be difficult in the beginning. Here are a couple of scenarios where you could consider using interfaces:
- Limited access: In the
AttackState
, we can’t access theAddHealth()
method of theHealth
class because it’s not part of theIDamagable
interface. Is this a limitation that will get in the way later in the project? - Design flexibility: There’s nothing to prevent preventing us from defining a new interface, say
IHealable
, that includes theAddHealth()
method. - Practical considerations: In some cases, it might be unlikely that other objects would need to reuse the
AddHealth()
method. If so, we might continue accessing the object as a Health type when we need this specific functionality. This lowers the complexity of our project.
The key takeaway here is that interface design doesn’t always have a clear right or wrong way to approach it. As you gain experience, you’ll develop an intuition for when and how to use interfaces effectively.
Let’s implement this solution into our project to give you a hands-on understanding of how interfaces are used in practice.
Starter Project – Player AttackState
This article builds upon the concepts introduced in Article 4, where an AttackState
was added to the EnemyNPC. The same principles have been applied to add attack behavior to the Player character. To follow along, refer to the starter project for Article 5 on GitHub: _Scripts > Article 5 > Start.
The Player is now able to draw a weapon and perform an attack animation.
The following changes have been made to implement the Player’s attack behavior:
- The Z key now triggers the
DrawWeaponState
to draw and holster the weapon. - The
WeaponHelper
script has been modified to support drawing and holstering the weapon. WeaponPickUpInteraction
has been updated to trigger theWeaponHelper
script.- A new
DrawWeaponState
, inheriting fromMovementState
, allows movement while drawing/holstering the weapon. MovementState
now stores animation speed inAgentStats
before applying it, addressing animation issues where character would suddenly stop.MoveDrawWeaponTransition
andDelayedTransition
are used forDrawWeaponState
transitions.- The
AttackState
from Article 4 is reused for Player attacks. - Interaction input (LMB) now triggers attacks when the weapon is drawn, with changes implemented in the
PlayerAgent
script.
These modifications enable the Player to draw weapons and perform attack animations, with hit effects and animations already implemented.
Damageable system
The new damage system introduces an IDamagable
interface, detected using a new HitDetector component. This system allows the AttackState
to find all IDamagable objects within attack range and apply damage.
AttackState
uses HitDetector
, a MonoBehaviour with a lifespan that isn’t tied to the AttackSate
. That’s why it isn’t shown as composition, even though it works this way. AttackState
depends on DamageData
and the IDamagable
interface, shown as a dashed line with an arrow. IDamagable
depends on the DamageData
. Lastly HitDetector
uses the SphereDetector
but again, it isn’t a true composition as both of those are MonoBehaviours.
The AttackState
will use HitDetector
to find all the GameObjects with an IDamagable component on them which are within attacking range. It will pass to it the DamageData
for it to act on this data. Since we already have an InteractionDetector
script that uses a Physics.OverlapSphere()
we will just refactor this code by placing it inside the SphereDetector
script.
Let’s start by creating the IDamagable
interface:
public interface IDamagable
{
void TakeDamage(DamageData damageData);
}
public class DamageData
{
public int DamageAmount { get; set; }
}
Using a DamageData
object instead of passing damage values directly allows us to easily expand the damage-related information, such as adding knockback effects later.
public class DamageData
{
public GameObject Sender { get; set; }
public int DamageAmount { get; set; }
}
By passing a DamageData object we can easily add new parameters. Otherwise we would have to modify the definition of our interface and every class that implements it.
Health
, PlayAudioGetHitEffect
, and PlayerGetHitEffect
all implement the IDamagable
interface. This way, all of those can be interfaced with by our AttackState
. PlayerGetHitEffect
uses PlayerTakeDamageEffect
to add a screen overlay effect to provide Hit feedback to our player.
Implementing IDamagable
The three main damage effects we’ll create that will implement the IDamagable
interface are:
Health
: Manages the object’s health value and reacts to damage inflictedPlayAudioGetHitEffect
: Plays a sound effect when hitPlayerGetHitEffect
: Triggers a visual effect to indicate damage to the player
Here is an example implementation of the Health
script:
public class Health : MonoBehaviour, IDamagable
{
[field: SerializeField]
public int CurrentHealth { get; private set; }
[SerializeField]
private int m_maxHealth = 2;
[SerializeField]
private bool m_isInvincible = false;
public event Action OnHit;
private void Awake()
{
CurrentHealth = m_maxHealth;
}
public void SetInvincible(bool isInvincible)
=> m_isInvincible = isInvincible;
public void TakeDamage(DamageData damageData)
{
if (m_isInvincible)
return;
CurrentHealth -= damageData.DamageAmount;
OnHit?.Invoke();
}
}
public class PlayAudioGetHitEffect : MonoBehaviour, IDamagable
{
[SerializeField]
private AudioSource m_audioSource;
public void TakeDamage(DamageData damageData)
{
m_audioSource.Play();
}
}
Inside the Health
script we define a CurrentHealth
value and an OnHit
event Action. Note that we inherit from MonoBehaviour and implement the IDamagable
interface. In the TakeDamage()
implementation we affect the CurrentHealth
value and call the OnHit
event (unless the object is Invincible). We can see how we can utilize the DamageData
values here. The PlayAudioGetHitEffect
makes the audioSource play in its TakeDamage()
method.
The remaining scripts PlayerGetHitEffect
and PlayerTakeDamageScreenEffect
can be found on Github in the Scripts for Articles > Article 5 folder.
The player character (named Ellen_5_End) now has a Health component,
PlayAudioGetHitEffect
and PlayerGetHitEffect
scripts, while the Enemy character has only Health
and PlayAudioGetHitEffect
.
Next we need to focus on detecting the IDamagable objects when the Player or Enemy performs its attack.
Detecting when we hit something
To detect collisions with IDamageable objects, we’ll implement a hit detection system by refactoring the existing interaction detector. This involves extracting the OverlapSphere casting logic into a reusable detector - SphereDetector
:
public class SphereDetector : MonoBehaviour
{
[SerializeField] private float m_detectionRange = 1.0f;
[SerializeField] private float m_detectionRadius = 0.5f;
[SerializeField] private float m_height = 1.0f;
[SerializeField] private LayerMask m_detectionLayer;
public Color GizmoColor { get; set; } = Color.blue;
public Collider[] DetectObjects()
{
Collider[] results = Physics.OverlapSphere(GetDetectionPosition(), m_detectionRadius, m_detectionLayer);
return results;
}
public Vector3 GetDetectionPosition()
{
return transform.position + Vector3.up * m_height + transform.forward * m_detectionRange;
}
private void OnDrawGizmosSelected()
{
Gizmos.color = GizmoColor;
Gizmos.DrawWireSphere(GetDetectionPosition(), m_detectionRadius);
}
}
public class InteractionDetector : MonoBehaviour
{
[SerializeField]
private SphereDetector m_sphereDetector;
public IInteractable CurrentInteractable { get; private set; }
private Highlight m_currentHighlight;
public void DetectInteractable()
{
Collider[] result = m_sphereDetector.DetectObjects();
//rest of the code the same code as before
...
}
private void ClearCurrentInteractable()
{
...
}
}
We moved the logic for detecting objects from InteractionDetector to SphereDetector, allowing reuse of the detection functionality for IInteractable objects.
The SphereDetector component is added as a child of the Player GameObject (Ellen_5_End), and its parameters are configured in the Inspector.
Make sure to check if the new changes work correctly before proceeding. Next, create a HitDetector
:
public class HitDetector : MonoBehaviour
{
[SerializeField]
private SphereDetector m_sphereDetector;
public Dictionary<Collider, List<IDamagable>> PerformDetection()
{
Dictionary<Collider, List<IDamagable>> hitObjects = new Dictionary<Collider, List<IDamagable>>();
Collider[] result = m_sphereDetector.DetectObjects();
if (result.Length > 0)
{
foreach (var collider in result)
{
List<IDamagable> damagables = new(collider.GetComponents<IDamagable>());
if (damagables.Count > 0)
hitObjects.Add(collider, damagables);
}
}
return hitObjects;
}
}
In this script, SphereDetector is reused to detect colliders, and the IDamagable components attached to each detected collider are identified and stored in a Dictionary<Collider, List<IDamagable>>
. This allows efficient access to damageable components associated with specific colliders.
Dictionary data structure
A Dictionary is a collection that associates unique keys with specific values, making it ideal for tasks such as linking game objects or components to unique IDs, or storing configuration settings and lookup tables.
Dictionaries are part of the System.Collections.Generic namespace and can be resized as elements are added similar to Lists.
Dictionaries provide efficient value lookups by their keys, ensuring fast access. For example, in a tile-based strategy game, you might use a Dictionary<Vector3Int, List> to map each tile position (represented by a Vector3Int) to a list of units (List) occupying that tile. The uniqueness of keys ensures no duplicate entries, simplifying data management and enhancing performance when retrieving specific values.
Read more about Dictionary<TKey,TValue> data structure.
In our example context, we’re using a Dictionary<Collider, List<IDamagable>>
to store and retrieve all the IDamagable components attached to each detected Collider GameObject. This allows us to efficiently manage and access the damageable components associated with specific colliders.
Add the HitDetector component to Enemy and Player GameObjects, and create a new SphereDetector as a child object. Next add it as a reference to the HitDetector, setting its Detection Layer to Player, NPC, and Environment.
Ensure the Ellen_5_End and Enemy_NPC GameObjects have trigger colliders assigned to one of the detectable layers.
HitDetector was added to Player and Enemy GameObjects. It now references the SphereDetector instance.
To perform detection, reference the HitDetector in the PlayerAgent and EnemyAgent, passing it to the AttackState which will initiate detection and apply damage to each detected damageable object:
public class AttackState : State
{
...
private HitDetector m_hitDetector;
private float m_detectionDelay;
private float m_currentTime = 0;
public AttackState(AgentAnimations agentAnimations, IAgentMover mover, AgentStats agentStats, GameObject agent, HitDetector hitDetector, float detectionDelay)
{
...
m_hitDetector = hitDetector;
m_detectionDelay = detectionDelay;
m_agent = agent;
}
public override void Enter()
{
...
}
public override void Exit()
{
return;
}
protected override void StateUpdate(float deltaTime)
{
if (m_currentTime < 0)
return;
m_currentTime += Time.deltaTime;
if (m_currentTime >= m_detectionDelay)
{
m_currentTime = -1;
Dictionary<Collider, List<IDamagable>> result = m_hitDetector.PerformDetection();
foreach (var collider in result.Keys)
{
foreach (var damageable in result[collider])
{
damageable.TakeDamage(new DamageData() { Sender = m_agent, DamageAmount = 1 });
}
}
}
}
}
In AttackState, the HitDetector is used within the StateUpdate()
method to perform detection. The Agent GameObject is included in the DamageData object. The detectionDelay
parameter ensures synchronization between attack animations and damage effects. Each detected damageable object is processed to apply damage. Here is our PlayerAgent
script:
public class PlayerAgent : Agent
{
...
[SerializeField]
private HitDetector m_hitDetector;
protected override void Awake()
{
base.Awake();
...
m_hitDetector = GetComponent<HitDetector>();
}
protected override State StateFactory(Type stateType)
{
State newState = null;
if (stateType == typeof(JumpState))
{
...
}
else if (stateType == typeof(AttackState))
{
newState = new AttackState(m_agentAnimations, m_mover, m_agentStats, gameObject, m_hitDetector, 0.03f);
newState.AddTransition(new DelayedTransition(0.35f, typeof(MovementState)));
}
else
{
...
}
return newState;
}
protected override void Update()
{
...
}
}
PlayerAgent
needs to have a reference to the HitDetector and pass it and other parameters to the AttackState
for our system to work. The EnemyAgent
class requires the same changes with a difference of passing 0.2f as the detectionDelay
parameter. The fact that the data is duplicated is a code smell and indicates a need to refactor our code. You can browse changes made to the EnemyAgent
on Github.
Let’s press Play to test our setup:
Our screen overlay visual effect is now being triggered as a result of us implementing our Damage System.
We can see that the enemy performing the attack results in a screen overlay effect with the screen tinting red/purple for a brief moment. If you are running the project in the Editor you should also be able to hear the sound effect when the Player hits the Enemy.
We will improve the hit feedback in future iterations but first, let’s explore how to implement a damageable object using the IDamagable
interface.
Reusing IDamagable interface
As the player hits the tree object, it will trigger an effect with a shake animation and spawn particles.
To expand the versatility of our IDamagable
interface, we want to allow interactions with a variety of objects in the game world, such as trees, which should provide visual feedback when hit. To achieve this, we’ll use the provided ShakeTransformEffect
and SpawnParticleEffect
scripts along with simple wood chip particles. These components are already attached to the tree object in our scene.
The Tree GameObject has ShakeTransformEffect, SpawnParticleEffect and PlayAudioGetHitEffect components attached to it.
Additionally, the PlayAudioGetHitEffect component is attached to the tree object leveraging Unity’s component system for composition. By using this approach we are able to create reusable “hit effect” scripts that can be applied to different game objects.
The tree object already has a Collider and is assigned to the Environment layer. To reuse our damage system, we’ll need to modify these scripts to implement the IDamagable
interface.
Let’s start by modifying the ShakeTransformEffect
script:
public class ShakeTransformEffect : MonoBehaviour, IDamagable
{
...
private Vector3 originalPosition;
private Coroutine shakeCoroutine;
private void Awake()
{
originalPosition = transform.position;
}
public void Shake()
{
...
}
private IEnumerator ShakeCoroutine()
{
...
}
public void TakeDamage(DamageData damageData)
{
Shake();
}
}
In this implementation, the TakeDamage()
method simply calls the Shake()
method, causing the object to shake when it takes damage. You can view the complete version of this script on GitHub.
Next, let’s implement the IDamagable
interface in the SpawnParticleEffect
script:
public class SpawnParticleEffect : MonoBehaviour, IDamagable
{
...
private void Awake()
{
InitializeParticleSystem();
}
private void InitializeParticleSystem()
{
...
}
public void SpawnParticles(Vector3 position, Vector3 normal)
{
...
}
public void TakeDamage(DamageData damageData)
{
Vector3 position = transform.position;
position.y = damageData.Sender.transform.position.y;
Vector3 normal = (damageData.Sender.transform.position - transform.position).normalized;
SpawnParticles(position, normal);
}
}
The SpawnParticleEffect
uses the Sender
parameter from DamageData
to calculate the normal vector from the tree object to the detection sphere. This allows the wood chip particles to fly in the direction of the Player, rather than dispersing randomly around the tree. This technique could be extended to implement a knockback effect for characters as well.
Integrating the Damage System with the state pattern
In Article 2, we introduced the state design pattern, and demonstrated how it’s easy to add more behaviors to the existing codebase. Now, we will expand our character behaviors with DeathState
and GetHitState
and integrate them with our Damage System.
The first step, is adding a death animation using the ragdoll wizard
Our project already includes a GetHit
animation for characters. To add a death animation, we can use Unity’s ragdoll wizard. Here’s how to do it:
- In the Hierarchy, right-click on the Enemy GameObject.
- Select Create > 3D Object > Ragdoll.
- Assign the appropriate bones to their respective slots and click the Create button.
Accessing the ragdoll wizard in Unity
All the bones have now been assigned.
To enable ragdoll physics on your character, simply disable its Animator component. This will stop the Animator from driving the bone movements, causing the character to collapse onto the floor – an effective simple death animation.
Enable ragdoll physics by disabling the Animator component.
Next, we need to add NavMeshNPCDeathState
, GetHitState
, NPCDeathTransition
, and GetHitTransition
to our project. These scripts are already present in the project, so our task is to integrate them with the PlayerAgent
and EnemyAgent
scripts.
Here are the transition scripts:
public class NPCDeathTransition : ITransitionRule
{
public Type NextState => typeof(NavMeshNPCDeathState);
private Health m_health;
public NPCDeathTransition(Health health)
{
m_health = health;
}
public bool ShouldTransition(float deltaTime)
{
return m_health.CurrentHealth <= 0;
}
}
public class GetHitTransition : IEventTransitionRule
{
public Type NextState => typeof(GetHitState);
private Health m_health;
private bool m_shouldTransition = false;
public GetHitTransition(Health health)
{
m_health = health;
}
public bool ShouldTransition(float deltaTime)
{
return m_shouldTransition;
}
public void Subscribe()
{
m_health.OnHit += TriggerTransition;
}
private void TriggerTransition()
{
m_shouldTransition = true;
}
public void Unsubscribe()
{
m_health.OnHit -= TriggerTransition;
}
}
As shown, both transitions utilize the Health component, which implements the IDamagable
interface. When the Damage System calls the TakeDamage()
method on the Health object, the transitions either check the CurrentHealth
value or wait for the OnHit
event to trigger the transition to their respective states.
The Player is now affected by the Enemy attack and vice versa. Our Player is also able to hit the Tree GameObject causing it to play its own hit effect.
To see the complete code used for integrating these with the PlayerAgent
and EnemyAgent
scripts, visit the GitHub repository and open the scene inside the _Scripts > Article 5 Result folder.
Refactoring StateFactory method to separate objects
In Article 2, we implemented the state design pattern in our project. This allowed us to add new states without having to change the Agent
each time. However, the Agent
implementations still depend on all the specific state classes they use. Ideally, an Agent
should only be aware of the abstract State
, not the concrete State
implementations.
Agent
implementation depends on all the State
classes that it uses.
This high coupling between the Agent
and its states means that any modification to any State
implementation may require changes across multiple Agent
implementations. This scenario contradicts our goal of making the Agent
implementations responsible for only directing the flow of control in our character setup. Currently, the Agent
class changes for multiple reasons – when we add or modify a state, and when we need to pass a new reference object to our states.
Fortunately, we can refactor our code by extracting the StateFactory()
method into a separate class:
The
Agent
class now uses a non-abstract StateFactory
class that contains StateFactoryData
. Each StateFactory
implementation expects to receive an instance of a respective StateFactoryData
implementation. Note that this diagram might be oversimplified compared to the actual code to maintain readability.
This refactoring restores the Agent's
responsibility to only pass specific references around, while the StateFactory
implementations handle the creation of new State
instances. As a result, the Agent
class only needs to know about the abstract State
class, rather than being tightly coupled to all the individual state implementations. Here is the StateFactory
script:
public class StateFactory
{
protected StateFactoryData m_stateFactoryData;
public StateFactory(StateFactoryData stateFactoryData)
{
m_stateFactoryData = stateFactoryData;
}
public virtual State CreateState(Type stateType)
{
State newState = null;
if (stateType == typeof(MovementState))
{
newState = new MovementState(m_stateFactoryData.AgentMover, m_stateFactoryData.GroundDetector, m_stateFactoryData.AgentAnimations, m_stateFactoryData.MovementInput, m_stateFactoryData.AgentStats);
newState.AddTransition(new GroundedFallTransition(m_stateFactoryData.GroundDetector));
}
else if (stateType == typeof(FallState))
{
newState = new FallState(m_stateFactoryData.AgentMover, m_stateFactoryData.AgentAnimations, m_stateFactoryData.MovementInput, m_stateFactoryData.AgentStats);
newState.AddTransition(new FallLandTransition(m_stateFactoryData.GroundDetector));
}
else if (stateType == typeof(LandState))
{
newState = new LandState(m_stateFactoryData.AgentAnimations, m_stateFactoryData.AgentStats);
newState.AddTransition(new LandMovementTransition());
}
else
{
throw new Exception($"Type not handled {stateType}");
}
return newState;
}
}
In this refactoring, the StateFactory
class now contains the logic that was previously embedded in the StateFactory()
method. To facilitate this, we use the StateFactoryData object to pass the necessary references into the StateFactory constructor. C# properties are used to easily assign these fields, and you may consider making the setters private and adding a constructor to prevent other objects from modifying the references.
Here’s the StateFactoryData
class:
public class StateFactoryData
{
public IAgentMover AgentMover { get; set; }
public IAgentMovementInput MovementInput { get; set; }
public GroundedDetector GroundDetector { get; set; }
public AgentAnimations AgentAnimations { get; set; }
public AgentStats AgentStats { get; set; }
}
With this structure, the Agent
class is simplified, focusing on passing information and directing control flow between the components:
public abstract class Agent : MonoBehaviour
{
protected IAgentMover m_mover;
protected IAgentMovementInput m_input;
protected GroundedDetector m_groundDetector;
protected AgentAnimations m_agentAnimations;
protected State m_currentState;
protected AgentStats m_agentStats;
protected StateFactory _stateFactory;
protected virtual void Awake()
{
m_input = GetComponent<IAgentMovementInput>();
m_groundDetector = GetComponent<GroundedDetector>();
m_agentAnimations = GetComponent<AgentAnimations>();
m_mover = GetComponent<IAgentMover>();
m_agentStats = GetComponent<AgentStats>();
_stateFactory = new StateFactory(
new StateFactoryData
{
AgentStats = m_agentStats,
MovementInput = m_input,
GroundDetector = m_groundDetector,
AgentAnimations = m_agentAnimations,
AgentMover = m_mover
});
}
protected virtual void Start()
{
TransitionToState(typeof(MovementState));
}
private void TransitionToState(Type stateType)
{
State newState = _stateFactory.CreateState(stateType);
...
}
protected virtual void Update()
{
...
}
protected virtual void FixedUpdate()
{
...
}
}
The Agent
class has become shorter and with a focused single responsibility. It now primarily manages the flow of information and control between its components, ensuring that the Agent operates as expected.
For more details on the StateFactory
implementation for both the Player and NPCs, you can explore the GitHub repository by opening a scene inside the Scripts for Articles > Article 5 > 3 Agent StateFactory refactoring folder.
Conclusion
Throughout this article series, we’ve made significant strides in refactoring and improving our game project’s architecture. In this article our focus was to create a reusable and flexible Damage System. We did that by leveraging interfaces, successfully decoupling damage-related logic from specific classes, and enabling different objects to interact with the damage system seamlessly. This modular design not only enhances code reusability but also simplifies the addition of new features, as components can now handle damage in a consistent and extensible manner.
We also refactored our Agent
class significantly. We started with a massive monolithic AgentMonolithic
class in Article 1 that had too many responsibilities, making it cumbersome to reuse for both our Player and NPCs. Through refactoring, we broke down this class into smaller, more manageable components. As a result, none of our classes now exceed 100 lines of code, and each script has a clearly defined purpose. While no software is ever perfect and one could argue for different ways to refactor it further, the project is now more extendable than the original, even after the addition of multiple new game mechanics.
Our hope is that by applying the tips and techniques discussed in this series to your own projects, you can keep your codebase flexible and maintainable, allowing you to continue developing your games with greater ease and confidence.