Skip to main content

5.12 - Interfaces

Interfaces are a powerful feature in C# that allow you to define contracts that classes must implement. They are a fundamental tool for creating flexible, modular, and extensible code, especially in game development.

What are Interfaces?

An interface is a contract that defines a set of methods, properties, events, or indexers that a class must implement. Unlike abstract classes, interfaces cannot provide any implementation—they only define what a class should do, not how it should do it.

Interfaces are particularly useful when you want to:

  • Define a contract that multiple unrelated classes can implement
  • Enable a form of multiple inheritance (a class can implement multiple interfaces)
  • Create loosely coupled systems where components interact through well-defined interfaces
  • Enable polymorphism across unrelated classes

Declaring Interfaces

To declare an interface in C#, you use the interface keyword:

public interface IDamageable
{
void TakeDamage(int amount);
int Health { get; }
bool IsDestroyed { get; }
}

Key points about interfaces:

  • Interface names conventionally start with "I" (e.g., IDamageable)
  • Interfaces can contain methods, properties, events, and indexers
  • Interfaces cannot contain fields, constructors, or destructors
  • All members of an interface are implicitly public
  • Interface members cannot have access modifiers
  • Interface members cannot have implementations (before C# 8.0)

Implementing Interfaces

To implement an interface, a class must provide implementations for all the members defined in the interface:

public class Enemy : MonoBehaviour, IDamageable
{
[SerializeField] private int maxHealth = 100;
private int currentHealth;

private void Awake()
{
currentHealth = maxHealth;
}

// Implementing the TakeDamage method from IDamageable
public void TakeDamage(int amount)
{
currentHealth -= amount;
Debug.Log($"Enemy takes {amount} damage. Health: {currentHealth}/{maxHealth}");

if (currentHealth <= 0)
{
Die();
}
}

// Implementing the Health property from IDamageable
public int Health => currentHealth;

// Implementing the IsDestroyed property from IDamageable
public bool IsDestroyed => currentHealth <= 0;

private void Die()
{
Debug.Log("Enemy destroyed!");
Destroy(gameObject);
}
}

public class DestructibleObject : MonoBehaviour, IDamageable
{
[SerializeField] private int maxHealth = 50;
[SerializeField] private GameObject destroyedVersionPrefab;

private int currentHealth;
private bool isDestroyed = false;

private void Awake()
{
currentHealth = maxHealth;
}

// Implementing the TakeDamage method from IDamageable
public void TakeDamage(int amount)
{
currentHealth -= amount;
Debug.Log($"Object takes {amount} damage. Health: {currentHealth}/{maxHealth}");

if (currentHealth <= 0 && !isDestroyed)
{
Break();
}
}

// Implementing the Health property from IDamageable
public int Health => currentHealth;

// Implementing the IsDestroyed property from IDamageable
public bool IsDestroyed => isDestroyed;

private void Break()
{
isDestroyed = true;
Debug.Log("Object destroyed!");

// Spawn the destroyed version
if (destroyedVersionPrefab != null)
{
Instantiate(destroyedVersionPrefab, transform.position, transform.rotation);
}

// Hide or destroy the original object
gameObject.SetActive(false);
}
}

In this example, both Enemy and DestructibleObject implement the IDamageable interface, even though they are otherwise unrelated classes. This allows them to be treated polymorphically through the interface.

Using Interfaces for Polymorphism

One of the main benefits of interfaces is that they enable polymorphism across unrelated classes. You can write code that works with any object that implements a particular interface, regardless of its actual type:

public class Weapon : MonoBehaviour
{
[SerializeField] private int damage = 10;

private void Update()
{
if (Input.GetMouseButtonDown(0))
{
Fire();
}
}

private void Fire()
{
Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);
RaycastHit hit;

if (Physics.Raycast(ray, out hit, 100f))
{
// Check if the hit object implements IDamageable
IDamageable damageable = hit.collider.GetComponent<IDamageable>();

if (damageable != null)
{
// Deal damage to the object
damageable.TakeDamage(damage);
Debug.Log($"Hit {hit.collider.name}! Remaining health: {damageable.Health}");
}
}
}
}

In this example, the Weapon class doesn't need to know the specific type of object it's hitting. It only needs to know that the object implements the IDamageable interface. This makes the code more flexible and easier to extend.

Multiple Interface Implementation

A class can implement multiple interfaces, which is a way to achieve a form of multiple inheritance in C#:

public interface IInteractable
{
void Interact(GameObject interactor);
string GetInteractionPrompt();
}

public interface IPickupable
{
void Pickup(GameObject collector);
float Weight { get; }
}

public class HealthPotion : MonoBehaviour, IInteractable, IPickupable
{
[SerializeField] private int healAmount = 20;
[SerializeField] private float itemWeight = 0.5f;

// Implementing IInteractable
public void Interact(GameObject interactor)
{
Debug.Log($"{interactor.name} interacts with Health Potion");
Pickup(interactor);
}

public string GetInteractionPrompt()
{
return "Press E to pick up Health Potion";
}

// Implementing IPickupable
public void Pickup(GameObject collector)
{
Debug.Log($"{collector.name} picks up Health Potion");

// Try to heal the collector
IDamageable damageable = collector.GetComponent<IDamageable>();
if (damageable != null && damageable.Health < 100) // Assuming max health is 100
{
// In a real game, you would have a method to heal rather than directly manipulating health
// This is just for demonstration
damageable.TakeDamage(-healAmount); // Negative damage = healing

// Destroy the potion after use
Destroy(gameObject);
}
}

public float Weight => itemWeight;
}

In this example, HealthPotion implements both IInteractable and IPickupable interfaces, allowing it to be used in different contexts.

Interface Inheritance

Interfaces can inherit from other interfaces, creating a hierarchy of interfaces:

public interface IEntity
{
string Name { get; }
Vector3 Position { get; }
}

public interface ICharacter : IEntity
{
int Level { get; }
void LevelUp();
}

public interface IEnemy : IEntity, IDamageable
{
void Attack(ICharacter target);
int DamageOutput { get; }
}

public class Player : MonoBehaviour, ICharacter
{
[SerializeField] private string playerName = "Hero";
[SerializeField] private int playerLevel = 1;

// Implementing IEntity (inherited by ICharacter)
public string Name => playerName;
public Vector3 Position => transform.position;

// Implementing ICharacter
public int Level => playerLevel;

public void LevelUp()
{
playerLevel++;
Debug.Log($"{playerName} leveled up to level {playerLevel}!");
}
}

public class Goblin : MonoBehaviour, IEnemy
{
[SerializeField] private string enemyName = "Goblin";
[SerializeField] private int maxHealth = 30;
[SerializeField] private int baseDamage = 5;

private int currentHealth;

private void Awake()
{
currentHealth = maxHealth;
}

// Implementing IEntity (inherited by IEnemy)
public string Name => enemyName;
public Vector3 Position => transform.position;

// Implementing IDamageable (inherited by IEnemy)
public void TakeDamage(int amount)
{
currentHealth -= amount;
Debug.Log($"{enemyName} takes {amount} damage. Health: {currentHealth}/{maxHealth}");

if (currentHealth <= 0)
{
Die();
}
}

public int Health => currentHealth;
public bool IsDestroyed => currentHealth <= 0;

// Implementing IEnemy
public void Attack(ICharacter target)
{
Debug.Log($"{enemyName} attacks {target.Name}!");

// In a real game, you would check distance, line of sight, etc.
// This is just for demonstration

// Try to damage the target
IDamageable damageable = (target as MonoBehaviour)?.GetComponent<IDamageable>();
if (damageable != null)
{
damageable.TakeDamage(DamageOutput);
}
}

public int DamageOutput => baseDamage;

private void Die()
{
Debug.Log($"{enemyName} has been defeated!");
Destroy(gameObject);
}
}

In this example, ICharacter inherits from IEntity, and IEnemy inherits from both IEntity and IDamageable. This creates a hierarchy of interfaces that can be used to model complex relationships between different types of game objects.

Explicit Interface Implementation

Sometimes, you might want to implement the same method from different interfaces in different ways, or you might want to hide interface implementations from the public API of your class. In these cases, you can use explicit interface implementation:

public interface ISaveable
{
void Save();
void Load();
}

public interface IResettable
{
void Reset();
}

public class PlayerData : MonoBehaviour, ISaveable, IResettable
{
[SerializeField] private string playerName = "Hero";
[SerializeField] private int playerLevel = 1;
[SerializeField] private int playerHealth = 100;

// Explicit implementation of ISaveable
void ISaveable.Save()
{
Debug.Log("Saving player data to file...");
PlayerPrefs.SetString("PlayerName", playerName);
PlayerPrefs.SetInt("PlayerLevel", playerLevel);
PlayerPrefs.SetInt("PlayerHealth", playerHealth);
PlayerPrefs.Save();
}

void ISaveable.Load()
{
Debug.Log("Loading player data from file...");
playerName = PlayerPrefs.GetString("PlayerName", "Hero");
playerLevel = PlayerPrefs.GetInt("PlayerLevel", 1);
playerHealth = PlayerPrefs.GetInt("PlayerHealth", 100);
}

// Explicit implementation of IResettable
void IResettable.Reset()
{
Debug.Log("Resetting player data to defaults...");
playerName = "Hero";
playerLevel = 1;
playerHealth = 100;
}

// Public method that uses the interfaces
public void SaveGame()
{
// Call the explicit interface implementation
((ISaveable)this).Save();
}

public void LoadGame()
{
// Call the explicit interface implementation
((ISaveable)this).Load();
}

public void ResetGame()
{
// Call the explicit interface implementation
((IResettable)this).Reset();
}
}

With explicit interface implementation:

  • The interface methods are not accessible directly on the class
  • You must cast the object to the interface type to access the methods
  • This helps keep the public API of your class clean
  • It allows you to implement the same method name from different interfaces in different ways

Default Interface Methods (C# 8.0+)

Starting with C# 8.0, interfaces can include default implementations for methods. This allows you to add new methods to an interface without breaking existing implementations:

public interface ILogger
{
void LogMessage(string message);

// Default implementation
void LogWarning(string message)
{
LogMessage($"WARNING: {message}");
}

// Default implementation
void LogError(string message)
{
LogMessage($"ERROR: {message}");
}
}

public class ConsoleLogger : ILogger
{
public void LogMessage(string message)
{
Debug.Log(message);
}

// No need to implement LogWarning or LogError, they use the default implementation
}

public class FileLogger : ILogger
{
private string logFilePath;

public FileLogger(string filePath)
{
logFilePath = filePath;
}

public void LogMessage(string message)
{
// In a real implementation, you would append to a file
Debug.Log($"[FILE] {message}");
}

// Override the default implementation
public void LogWarning(string message)
{
LogMessage($"CUSTOM WARNING: {message}");
}
}

Default interface methods are useful for:

  • Adding new functionality to interfaces without breaking existing code
  • Providing common implementations that most classes can use
  • Creating extension-like methods that operate on interface types

Note that Unity currently uses .NET Standard 2.0, which doesn't support default interface methods. However, newer versions of Unity are moving toward .NET Standard 2.1 and beyond, which will support this feature.

Interfaces vs. Abstract Classes

Interfaces and abstract classes are both used to define contracts that derived types must follow, but they have some key differences:

FeatureInterfaceAbstract Class
ImplementationCannot provide implementation (before C# 8.0)Can provide implementation for some methods
InheritanceA class can implement multiple interfacesA class can inherit from only one abstract class
FieldsCannot contain fieldsCan contain fields
Access ModifiersAll members are implicitly publicCan use access modifiers
ConstructorCannot have constructorsCan have constructors
Static MembersCannot have static membersCan have static members

When to use interfaces:

  • When unrelated classes need to share a contract
  • When you want a class to inherit from multiple sources
  • When you're defining a contract that can be implemented by many different classes
  • When you're focusing on what a class can do, rather than what it is

When to use abstract classes:

  • When you want to share code among related classes
  • When the classes that will inherit from the abstract class have common behavior or attributes
  • When you want to provide a default implementation for some methods
  • When you need to define non-public members (fields, methods) that derived classes can use

Interfaces in Unity

Unity uses interfaces extensively in its architecture. Here are some examples:

Built-in Unity Interfaces

Unity provides several built-in interfaces that you can implement in your scripts:

  • IComparable: For objects that can be compared and sorted
  • IEnumerable: For collections that can be enumerated
  • ISerializationCallbackReceiver: For custom serialization logic
  • IPointerClickHandler, IDragHandler, etc.: For handling UI events

Custom Interfaces in Unity

You can create your own interfaces to define contracts for your game objects:

// Define an interface for objects that can be interacted with
public interface IInteractable
{
void Interact(GameObject interactor);
string GetInteractionPrompt();
bool CanInteract(GameObject interactor);
}

// Implement the interface in various game objects
public class Door : MonoBehaviour, IInteractable
{
[SerializeField] private bool isLocked = false;
[SerializeField] private string keyName = "RedKey";

public void Interact(GameObject interactor)
{
if (isLocked)
{
// Check if the interactor has the key
Inventory inventory = interactor.GetComponent<Inventory>();
if (inventory != null && inventory.HasItem(keyName))
{
Unlock(interactor);
}
else
{
Debug.Log("The door is locked. You need a key.");
}
}
else
{
// Open the door
Open();
}
}

public string GetInteractionPrompt()
{
return isLocked ? "Press E to unlock door" : "Press E to open door";
}

public bool CanInteract(GameObject interactor)
{
return true; // Anyone can try to interact with a door
}

private void Unlock(GameObject interactor)
{
isLocked = false;
Debug.Log($"{interactor.name} unlocked the door with the {keyName}!");
}

private void Open()
{
Debug.Log("The door opens!");
// Animation and logic for opening the door
}
}

public class Chest : MonoBehaviour, IInteractable, ILockable
{
[SerializeField] private bool isLocked = true;
[SerializeField] private string keyName = "BlueKey";
[SerializeField] private GameObject[] lootItems;

private bool isOpen = false;

public void Interact(GameObject interactor)
{
if (isLocked)
{
// Check if the interactor has the key
Inventory inventory = interactor.GetComponent<Inventory>();
if (inventory != null && inventory.HasItem(keyName))
{
Unlock(interactor);
}
else
{
Debug.Log("The chest is locked. You need a key.");
}
}
else if (!isOpen)
{
// Open the chest
Open(interactor);
}
else
{
Debug.Log("The chest is already open and empty.");
}
}

public string GetInteractionPrompt()
{
if (isLocked)
return "Press E to unlock chest";
else if (!isOpen)
return "Press E to open chest";
else
return "Chest is empty";
}

public bool CanInteract(GameObject interactor)
{
return true; // Anyone can try to interact with a chest
}

// ILockable implementation
public bool IsLocked => isLocked;

public void Lock()
{
isLocked = true;
Debug.Log("The chest is now locked.");
}

public void Unlock(GameObject unlocker)
{
isLocked = false;
Debug.Log($"{unlocker.name} unlocked the chest with the {keyName}!");
}

private void Open(GameObject opener)
{
isOpen = true;
Debug.Log("The chest opens!");

// Animation for opening the chest

// Give loot to the opener
Inventory inventory = opener.GetComponent<Inventory>();
if (inventory != null && lootItems.Length > 0)
{
foreach (GameObject lootItem in lootItems)
{
if (lootItem != null)
{
// In a real game, you would instantiate the item and add it to the inventory
Debug.Log($"{opener.name} found {lootItem.name} in the chest!");
}
}
}
}
}

public class NPC : MonoBehaviour, IInteractable, IDamageable
{
[SerializeField] private string npcName = "Villager";
[SerializeField] private string[] dialogueLines;
[SerializeField] private int maxHealth = 50;
[SerializeField] private bool isFriendly = true;

private int currentHealth;
private int currentDialogueLine = 0;

private void Awake()
{
currentHealth = maxHealth;
}

// IInteractable implementation
public void Interact(GameObject interactor)
{
if (dialogueLines.Length > 0)
{
// Display the current dialogue line
Debug.Log($"{npcName}: {dialogueLines[currentDialogueLine]}");

// Move to the next dialogue line, or loop back to the beginning
currentDialogueLine = (currentDialogueLine + 1) % dialogueLines.Length;
}
else
{
Debug.Log($"{npcName}: Hello there!");
}
}

public string GetInteractionPrompt()
{
return $"Press E to talk to {npcName}";
}

public bool CanInteract(GameObject interactor)
{
return true; // Anyone can talk to this NPC
}

// IDamageable implementation
public void TakeDamage(int amount)
{
if (isFriendly)
{
// Friendly NPCs might have special reactions to being attacked
Debug.Log($"{npcName}: Hey! Why are you attacking me?");
}

currentHealth -= amount;
Debug.Log($"{npcName} takes {amount} damage. Health: {currentHealth}/{maxHealth}");

if (currentHealth <= 0)
{
Die();
}
}

public int Health => currentHealth;

public bool IsDestroyed => currentHealth <= 0;

private void Die()
{
Debug.Log($"{npcName} has been defeated!");

// In a real game, you might want to handle NPC death differently
// For example, you might want to play a death animation, drop items, etc.

Destroy(gameObject);
}
}

// Interface for objects that can be locked and unlocked
public interface ILockable
{
bool IsLocked { get; }
void Lock();
void Unlock(GameObject unlocker);
}

// Simple inventory system
public class Inventory : MonoBehaviour
{
[SerializeField] private List<string> items = new List<string>();

public bool HasItem(string itemName)
{
return items.Contains(itemName);
}

public void AddItem(string itemName)
{
items.Add(itemName);
Debug.Log($"Added {itemName} to inventory");
}

public void RemoveItem(string itemName)
{
if (items.Remove(itemName))
{
Debug.Log($"Removed {itemName} from inventory");
}
}
}

// Interaction system
public class InteractionSystem : MonoBehaviour
{
[SerializeField] private float interactionRange = 2f;
[SerializeField] private LayerMask interactableLayers;
[SerializeField] private Text interactionPromptText;

private IInteractable currentInteractable;

private void Update()
{
// Check for interactable objects in range
CheckForInteractables();

// Handle interaction input
if (Input.GetKeyDown(KeyCode.E) && currentInteractable != null)
{
currentInteractable.Interact(gameObject);
}
}

private void CheckForInteractables()
{
// Reset current interactable
currentInteractable = null;

// Hide interaction prompt
if (interactionPromptText != null)
{
interactionPromptText.gameObject.SetActive(false);
}

// Cast a ray forward from the player
Ray ray = new Ray(transform.position, transform.forward);
RaycastHit hit;

if (Physics.Raycast(ray, out hit, interactionRange, interactableLayers))
{
// Check if the hit object implements IInteractable
IInteractable interactable = hit.collider.GetComponent<IInteractable>();

if (interactable != null && interactable.CanInteract(gameObject))
{
// Set current interactable
currentInteractable = interactable;

// Show interaction prompt
if (interactionPromptText != null)
{
interactionPromptText.text = interactable.GetInteractionPrompt();
interactionPromptText.gameObject.SetActive(true);
}
}
}
}
}

In this example, we have an IInteractable interface that defines a contract for objects that can be interacted with. We implement this interface in various game objects like Door, Chest, and NPC. We also have an ILockable interface for objects that can be locked and unlocked, which is implemented by Chest.

The InteractionSystem class uses these interfaces to provide a consistent way to interact with different types of objects in the game.

More Examples of Interfaces

Example 1: Combat System

// Interface for objects that can attack
public interface IAttacker
{
void Attack(IDamageable target);
int DamageOutput { get; }
float AttackRange { get; }
float AttackCooldown { get; }
}

// Interface for objects that can be damaged
public interface IDamageable
{
void TakeDamage(int amount);
int Health { get; }
bool IsDestroyed { get; }
}

// Interface for objects that can be healed
public interface IHealable
{
void Heal(int amount);
int MaxHealth { get; }
}

// Player class implementing multiple interfaces
public class Player : MonoBehaviour, IAttacker, IDamageable, IHealable
{
[SerializeField] private int baseDamage = 10;
[SerializeField] private float attackRange = 2f;
[SerializeField] private float attackCooldown = 1f;
[SerializeField] private int maxHealth = 100;

private int currentHealth;
private float lastAttackTime;

private void Awake()
{
currentHealth = maxHealth;
}

// IAttacker implementation
public void Attack(IDamageable target)
{
if (Time.time < lastAttackTime + attackCooldown)
{
Debug.Log("Attack on cooldown!");
return;
}

Debug.Log($"Player attacks for {DamageOutput} damage!");
target.TakeDamage(DamageOutput);
lastAttackTime = Time.time;
}

public int DamageOutput => baseDamage;
public float AttackRange => attackRange;
public float AttackCooldown => attackCooldown;

// IDamageable implementation
public void TakeDamage(int amount)
{
currentHealth -= amount;
Debug.Log($"Player takes {amount} damage. Health: {currentHealth}/{maxHealth}");

if (currentHealth <= 0)
{
Die();
}
}

public int Health => currentHealth;
public bool IsDestroyed => currentHealth <= 0;

// IHealable implementation
public void Heal(int amount)
{
int healthBefore = currentHealth;
currentHealth = Mathf.Min(currentHealth + amount, maxHealth);
int actualHealAmount = currentHealth - healthBefore;

Debug.Log($"Player healed for {actualHealAmount} health. Health: {currentHealth}/{maxHealth}");
}

public int MaxHealth => maxHealth;

private void Die()
{
Debug.Log("Player has died!");
// Handle player death...
}

private void Update()
{
// Check for attack input
if (Input.GetMouseButtonDown(0))
{
// Find a target to attack
Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);
RaycastHit hit;

if (Physics.Raycast(ray, out hit, attackRange))
{
IDamageable target = hit.collider.GetComponent<IDamageable>();
if (target != null)
{
Attack(target);
}
}
}
}
}

// Enemy class implementing IAttacker and IDamageable
public class Enemy : MonoBehaviour, IAttacker, IDamageable
{
[SerializeField] private int baseDamage = 5;
[SerializeField] private float attackRange = 1.5f;
[SerializeField] private float attackCooldown = 2f;
[SerializeField] private int maxHealth = 50;

private int currentHealth;
private float lastAttackTime;
private Transform player;

private void Awake()
{
currentHealth = maxHealth;
player = GameObject.FindGameObjectWithTag("Player")?.transform;
}

private void Update()
{
if (player == null) return;

// Check if player is in attack range
float distanceToPlayer = Vector3.Distance(transform.position, player.position);

if (distanceToPlayer <= attackRange && Time.time >= lastAttackTime + attackCooldown)
{
// Attack the player
IDamageable target = player.GetComponent<IDamageable>();
if (target != null)
{
Attack(target);
}
}
}

// IAttacker implementation
public void Attack(IDamageable target)
{
Debug.Log($"Enemy attacks for {DamageOutput} damage!");
target.TakeDamage(DamageOutput);
lastAttackTime = Time.time;
}

public int DamageOutput => baseDamage;
public float AttackRange => attackRange;
public float AttackCooldown => attackCooldown;

// IDamageable implementation
public void TakeDamage(int amount)
{
currentHealth -= amount;
Debug.Log($"Enemy takes {amount} damage. Health: {currentHealth}/{maxHealth}");

if (currentHealth <= 0)
{
Die();
}
}

public int Health => currentHealth;
public bool IsDestroyed => currentHealth <= 0;

private void Die()
{
Debug.Log("Enemy has died!");
Destroy(gameObject);
}
}

// Health potion class implementing IInteractable
public class HealthPotion : MonoBehaviour, IInteractable
{
[SerializeField] private int healAmount = 20;

public void Interact(GameObject interactor)
{
IHealable healable = interactor.GetComponent<IHealable>();

if (healable != null)
{
healable.Heal(healAmount);
Debug.Log($"Health potion heals {interactor.name} for {healAmount} health!");
Destroy(gameObject);
}
else
{
Debug.Log($"{interactor.name} cannot be healed!");
}
}

public string GetInteractionPrompt()
{
return $"Press E to use Health Potion (+{healAmount} HP)";
}

public bool CanInteract(GameObject interactor)
{
// Only allow interaction if the interactor can be healed and is not at full health
IHealable healable = interactor.GetComponent<IHealable>();
return healable != null && healable.Health < healable.MaxHealth;
}
}

// Combat manager
public class CombatManager : MonoBehaviour
{
// This method demonstrates how interfaces enable polymorphism
public void ProcessAttack(IAttacker attacker, IDamageable target)
{
// Check if the target is in range
float distance = Vector3.Distance(
(attacker as MonoBehaviour).transform.position,
(target as MonoBehaviour).transform.position
);

if (distance <= attacker.AttackRange)
{
attacker.Attack(target);
}
else
{
Debug.Log("Target is out of range!");
}
}

// This method demonstrates how interfaces can be used to filter objects
public List<IDamageable> FindDamageableTargetsInRadius(Vector3 center, float radius)
{
List<IDamageable> targets = new List<IDamageable>();

Collider[] colliders = Physics.OverlapSphere(center, radius);

foreach (Collider collider in colliders)
{
IDamageable damageable = collider.GetComponent<IDamageable>();
if (damageable != null && !damageable.IsDestroyed)
{
targets.Add(damageable);
}
}

return targets;
}
}

In this example, we have interfaces for different aspects of a combat system: IAttacker for objects that can attack, IDamageable for objects that can be damaged, and IHealable for objects that can be healed. We implement these interfaces in various game objects like Player and Enemy.

The CombatManager class demonstrates how interfaces enable polymorphism and can be used to filter objects based on their capabilities.

Example 2: Inventory System

// Interface for items that can be picked up and stored in inventory
public interface IInventoryItem
{
string ItemName { get; }
string Description { get; }
Sprite Icon { get; }
int MaxStackSize { get; }
float Weight { get; }

// Clone the item (for creating copies when splitting stacks, etc.)
IInventoryItem Clone();
}

// Interface for items that can be equipped
public interface IEquippable : IInventoryItem
{
EquipmentSlot Slot { get; }
void Equip(GameObject character);
void Unequip(GameObject character);
}

// Interface for items that can be consumed
public interface IConsumable : IInventoryItem
{
void Consume(GameObject character);
}

// Equipment slots
public enum EquipmentSlot
{
Head,
Chest,
Legs,
Feet,
Hands,
Weapon,
Shield,
Accessory
}

// Base item class
public abstract class Item : MonoBehaviour, IInventoryItem
{
[SerializeField] protected string itemName;
[SerializeField] protected string description;
[SerializeField] protected Sprite icon;
[SerializeField] protected int maxStackSize = 1;
[SerializeField] protected float weight = 1f;

public string ItemName => itemName;
public string Description => description;
public Sprite Icon => icon;
public int MaxStackSize => maxStackSize;
public float Weight => weight;

public abstract IInventoryItem Clone();
}

// Weapon class
public class Weapon : Item, IEquippable
{
[SerializeField] private int damage = 10;
[SerializeField] private float attackSpeed = 1f;
[SerializeField] private float attackRange = 2f;

public EquipmentSlot Slot => EquipmentSlot.Weapon;

public void Equip(GameObject character)
{
Debug.Log($"{character.name} equips {itemName}");

// Find the character's weapon handler
WeaponHandler weaponHandler = character.GetComponent<WeaponHandler>();
if (weaponHandler != null)
{
// Set the weapon's properties
weaponHandler.SetWeaponProperties(damage, attackSpeed, attackRange);

// Instantiate a visual representation of the weapon
// This would be more complex in a real game
Debug.Log($"Weapon stats: Damage={damage}, Speed={attackSpeed}, Range={attackRange}");
}
}

public void Unequip(GameObject character)
{
Debug.Log($"{character.name} unequips {itemName}");

// Find the character's weapon handler
WeaponHandler weaponHandler = character.GetComponent<WeaponHandler>();
if (weaponHandler != null)
{
// Reset the weapon's properties
weaponHandler.ResetWeaponProperties();
}
}

public override IInventoryItem Clone()
{
Weapon clone = Instantiate(this);
return clone;
}
}

// Armor class
public class Armor : Item, IEquippable
{
[SerializeField] private int defense = 5;
[SerializeField] private EquipmentSlot slot = EquipmentSlot.Chest;

public EquipmentSlot Slot => slot;

public void Equip(GameObject character)
{
Debug.Log($"{character.name} equips {itemName}");

// Find the character's armor handler
ArmorHandler armorHandler = character.GetComponent<ArmorHandler>();
if (armorHandler != null)
{
// Add the armor's defense
armorHandler.AddDefense(defense);

// Instantiate a visual representation of the armor
// This would be more complex in a real game
Debug.Log($"Armor stats: Defense={defense}, Slot={slot}");
}
}

public void Unequip(GameObject character)
{
Debug.Log($"{character.name} unequips {itemName}");

// Find the character's armor handler
ArmorHandler armorHandler = character.GetComponent<ArmorHandler>();
if (armorHandler != null)
{
// Remove the armor's defense
armorHandler.RemoveDefense(defense);
}
}

public override IInventoryItem Clone()
{
Armor clone = Instantiate(this);
return clone;
}
}

// Potion class
public class Potion : Item, IConsumable
{
[SerializeField] private int healthRestored = 20;

public void Consume(GameObject character)
{
Debug.Log($"{character.name} consumes {itemName}");

// Find the character's health component
IHealable healable = character.GetComponent<IHealable>();
if (healable != null)
{
// Heal the character
healable.Heal(healthRestored);
Debug.Log($"Potion restores {healthRestored} health!");
}
}

public override IInventoryItem Clone()
{
Potion clone = Instantiate(this);
return clone;
}
}

// Inventory slot
public class InventorySlot
{
public IInventoryItem Item { get; private set; }
public int Quantity { get; private set; }

public bool IsEmpty => Item == null || Quantity <= 0;

public InventorySlot()
{
Item = null;
Quantity = 0;
}

public bool CanAddItem(IInventoryItem item, int quantity = 1)
{
if (IsEmpty)
{
return true;
}

return Item.ItemName == item.ItemName && Quantity + quantity <= Item.MaxStackSize;
}

public bool AddItem(IInventoryItem item, int quantity = 1)
{
if (quantity <= 0) return false;

if (IsEmpty)
{
Item = item;
Quantity = quantity;
return true;
}

if (Item.ItemName == item.ItemName && Quantity + quantity <= Item.MaxStackSize)
{
Quantity += quantity;
return true;
}

return false;
}

public bool RemoveItem(int quantity = 1)
{
if (IsEmpty || quantity <= 0) return false;

if (Quantity <= quantity)
{
Item = null;
Quantity = 0;
return true;
}

Quantity -= quantity;
return true;
}

public void Clear()
{
Item = null;
Quantity = 0;
}
}

// Inventory system
public class InventorySystem : MonoBehaviour
{
[SerializeField] private int inventorySize = 20;
[SerializeField] private float maxWeight = 50f;

private InventorySlot[] slots;
private float currentWeight = 0f;

private void Awake()
{
// Initialize inventory slots
slots = new InventorySlot[inventorySize];
for (int i = 0; i < inventorySize; i++)
{
slots[i] = new InventorySlot();
}
}

public bool AddItem(IInventoryItem item, int quantity = 1)
{
if (item == null || quantity <= 0) return false;

// Check if adding this item would exceed the weight limit
if (currentWeight + item.Weight * quantity > maxWeight)
{
Debug.Log("Cannot add item: inventory weight limit exceeded!");
return false;
}

// First, try to add to existing stacks
for (int i = 0; i < slots.Length; i++)
{
if (!slots[i].IsEmpty && slots[i].Item.ItemName == item.ItemName)
{
int canAdd = Mathf.Min(quantity, item.MaxStackSize - slots[i].Quantity);
if (canAdd > 0)
{
slots[i].AddItem(item, canAdd);
quantity -= canAdd;
currentWeight += item.Weight * canAdd;

if (quantity <= 0)
{
Debug.Log($"Added {item.ItemName} to inventory (existing stack)");
return true;
}
}
}
}

// If we still have items to add, find empty slots
for (int i = 0; i < slots.Length; i++)
{
if (slots[i].IsEmpty)
{
int canAdd = Mathf.Min(quantity, item.MaxStackSize);
slots[i].AddItem(item, canAdd);
quantity -= canAdd;
currentWeight += item.Weight * canAdd;

if (quantity <= 0)
{
Debug.Log($"Added {item.ItemName} to inventory (new stack)");
return true;
}
}
}

// If we get here, we couldn't add all items
Debug.Log($"Could not add all {item.ItemName} to inventory. Inventory might be full.");
return quantity < 1; // Return true if we added at least some items
}

public bool RemoveItem(string itemName, int quantity = 1)
{
if (string.IsNullOrEmpty(itemName) || quantity <= 0) return false;

int remainingToRemove = quantity;

// Remove items from stacks
for (int i = 0; i < slots.Length && remainingToRemove > 0; i++)
{
if (!slots[i].IsEmpty && slots[i].Item.ItemName == itemName)
{
int canRemove = Mathf.Min(remainingToRemove, slots[i].Quantity);
float weightRemoved = slots[i].Item.Weight * canRemove;

slots[i].RemoveItem(canRemove);
remainingToRemove -= canRemove;
currentWeight -= weightRemoved;
}
}

return remainingToRemove <= 0;
}

public bool HasItem(string itemName, int quantity = 1)
{
if (string.IsNullOrEmpty(itemName) || quantity <= 0) return false;

int count = 0;

for (int i = 0; i < slots.Length; i++)
{
if (!slots[i].IsEmpty && slots[i].Item.ItemName == itemName)
{
count += slots[i].Quantity;
if (count >= quantity)
{
return true;
}
}
}

return false;
}

public IInventoryItem GetItem(string itemName)
{
for (int i = 0; i < slots.Length; i++)
{
if (!slots[i].IsEmpty && slots[i].Item.ItemName == itemName)
{
return slots[i].Item;
}
}

return null;
}

public void UseItem(int slotIndex)
{
if (slotIndex < 0 || slotIndex >= slots.Length || slots[slotIndex].IsEmpty)
{
Debug.Log("Invalid slot or empty slot!");
return;
}

IInventoryItem item = slots[slotIndex].Item;

// Check if the item is consumable
IConsumable consumable = item as IConsumable;
if (consumable != null)
{
consumable.Consume(gameObject);
RemoveItem(item.ItemName, 1);
return;
}

// Check if the item is equippable
IEquippable equippable = item as IEquippable;
if (equippable != null)
{
equippable.Equip(gameObject);
// Note: We don't remove equippable items from the inventory
return;
}

Debug.Log($"Cannot use {item.ItemName}!");
}

public InventorySlot[] GetAllSlots()
{
return slots;
}

public float GetCurrentWeight()
{
return currentWeight;
}

public float GetMaxWeight()
{
return maxWeight;
}
}

// Weapon handler component
public class WeaponHandler : MonoBehaviour
{
private int baseDamage = 1;
private float baseAttackSpeed = 1f;
private float baseAttackRange = 1f;

private int currentDamage;
private float currentAttackSpeed;
private float currentAttackRange;

private void Awake()
{
ResetWeaponProperties();
}

public void SetWeaponProperties(int damage, float attackSpeed, float attackRange)
{
currentDamage = damage;
currentAttackSpeed = attackSpeed;
currentAttackRange = attackRange;

Debug.Log($"Weapon properties set: Damage={currentDamage}, Speed={currentAttackSpeed}, Range={currentAttackRange}");
}

public void ResetWeaponProperties()
{
currentDamage = baseDamage;
currentAttackSpeed = baseAttackSpeed;
currentAttackRange = baseAttackRange;

Debug.Log("Weapon properties reset to base values");
}

public int GetDamage()
{
return currentDamage;
}

public float GetAttackSpeed()
{
return currentAttackSpeed;
}

public float GetAttackRange()
{
return currentAttackRange;
}
}

// Armor handler component
public class ArmorHandler : MonoBehaviour
{
private int baseDefense = 0;
private int currentDefense;

private void Awake()
{
currentDefense = baseDefense;
}

public void AddDefense(int defense)
{
currentDefense += defense;
Debug.Log($"Defense increased to {currentDefense}");
}

public void RemoveDefense(int defense)
{
currentDefense -= defense;
currentDefense = Mathf.Max(baseDefense, currentDefense); // Don't go below base defense
Debug.Log($"Defense decreased to {currentDefense}");
}

public int GetDefense()
{
return currentDefense;
}
}

In this example, we have interfaces for different types of inventory items: IInventoryItem for all items, IEquippable for items that can be equipped, and IConsumable for items that can be consumed. We implement these interfaces in various item classes like Weapon, Armor, and Potion.

The InventorySystem class demonstrates how interfaces enable polymorphism and can be used to handle different types of items in a consistent way.

Best Practices for Interfaces

  1. Use Interfaces for "Can-Do" Relationships: Interfaces are best used when there's a clear "can-do" relationship between the interface and the implementing class.

  2. Keep Interfaces Focused: Each interface should have a single responsibility. It's better to have multiple small interfaces than one large interface.

  3. Use Descriptive Names: Interface names should clearly describe the capability they represent. Use verb phrases (e.g., IComparable, IDamageable) or adjectives (e.g., IDisposable, IEnumerable).

  4. Prefer Composition Over Inheritance: When designing a class hierarchy, consider using interfaces and composition instead of deep inheritance hierarchies.

  5. Use Interface Segregation: Don't force classes to implement methods they don't need. Split large interfaces into smaller, more focused ones.

  6. Document Interface Contracts: Clearly document the expected behavior of interface methods so implementing classes know how to implement them correctly.

  7. Consider Default Implementations: In C# 8.0 and later, consider providing default implementations for methods that have a common implementation across most classes.

  8. Use Interfaces for Testability: Interfaces make it easier to create mock objects for testing.

  9. Be Consistent with Method Names: Use consistent method names across related interfaces to make them easier to use.

  10. Avoid "Marker" Interfaces: Interfaces with no members should generally be avoided. Consider using attributes or other mechanisms instead.

Conclusion

Interfaces are a powerful tool for designing flexible, modular, and extensible code in C#. They allow you to:

  • Define contracts that classes must implement
  • Enable polymorphism across unrelated classes
  • Achieve a form of multiple inheritance
  • Create loosely coupled systems
  • Design code around capabilities rather than types

In Unity, interfaces are particularly useful for creating systems where different types of game objects need to interact in consistent ways. By using interfaces effectively, you can create more organized, maintainable, and extensible code for your games.

In the next section, we'll explore structs, which are value types that can be used to create lightweight objects.

Practice Exercise

Exercise: Design a simple quest system for a game with the following requirements:

  1. Create an IQuest interface with:

    • Properties for quest name, description, and status
    • Methods for starting, completing, and failing the quest
    • A method for checking if the quest is complete
  2. Create an IQuestObjective interface with:

    • Properties for objective description and progress
    • Methods for updating progress and checking if the objective is complete
  3. Create at least two different types of quests (e.g., KillQuest, CollectionQuest) that implement the IQuest interface.

  4. Create at least two different types of objectives (e.g., KillObjective, CollectionObjective) that implement the IQuestObjective interface.

  5. Create a QuestManager class that can:

    • Add and remove quests
    • Track quest progress
    • Notify the player when quests are completed

Think about how interfaces help you create a flexible quest system that can handle different types of quests and objectives.