Skip to main content

5.10 - Abstract Classes and Methods

Abstract classes and methods are powerful features in C# that help you design robust class hierarchies. They allow you to define common functionality and structure while requiring derived classes to implement specific behaviors.

What are Abstract Classes?

An abstract class is a special type of class that cannot be instantiated directly. It serves as a base class for other classes to inherit from. Abstract classes can contain a mixture of:

  • Complete methods with implementations
  • Abstract methods without implementations
  • Fields, properties, and other members

The purpose of an abstract class is to provide a common definition that multiple derived classes can share. It's like a blueprint that defines what a set of classes should do, but not necessarily how they should do it.

Declaring Abstract Classes

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

public abstract class Shape
{
// Fields
protected string name;
protected Color color;

// Constructor
public Shape(string name, Color color)
{
this.name = name;
this.color = color;
}

// Regular method with implementation
public void SetColor(Color newColor)
{
color = newColor;
Debug.Log($"{name} color changed to {color}");
}

// Abstract method (no implementation)
public abstract float CalculateArea();

// Virtual method with default implementation
public virtual void Display()
{
Debug.Log($"This is a {color} {name}");
}
}

Key points about abstract classes:

  • They cannot be instantiated directly (new Shape() would cause a compilation error)
  • They can contain abstract methods, which have no implementation
  • They can also contain regular methods with implementations
  • They can contain fields, properties, constructors, and other members
  • A class that inherits from an abstract class must implement all its abstract methods, or be abstract itself

Abstract Methods

An abstract method is a method declared in an abstract class that has no implementation. It only provides a signature (name, parameters, and return type) that derived classes must implement.

To declare an abstract method, you use the abstract keyword and end the declaration with a semicolon instead of a method body:

public abstract float CalculateArea();

Abstract methods:

  • Cannot have an implementation in the abstract class
  • Must be implemented by non-abstract derived classes
  • Are implicitly virtual (they can be overridden)
  • Cannot be private (they need to be accessible to derived classes)
  • Cannot be static (they need to be overridden by derived classes)

Implementing Abstract Classes

To use an abstract class, you must create a derived class that inherits from it and implements all its abstract methods:

public class Circle : Shape
{
private float radius;

public Circle(float radius, Color color) : base("Circle", color)
{
this.radius = radius;
}

// Implementation of the abstract method
public override float CalculateArea()
{
return Mathf.PI * radius * radius;
}

// Optional: Override the virtual method
public override void Display()
{
base.Display();
Debug.Log($"Radius: {radius}, Area: {CalculateArea()}");
}
}

public class Rectangle : Shape
{
private float width;
private float height;

public Rectangle(float width, float height, Color color) : base("Rectangle", color)
{
this.width = width;
this.height = height;
}

// Implementation of the abstract method
public override float CalculateArea()
{
return width * height;
}

// Optional: Override the virtual method
public override void Display()
{
base.Display();
Debug.Log($"Width: {width}, Height: {height}, Area: {CalculateArea()}");
}
}

Now you can use these concrete classes:

// Create instances of concrete classes
Circle circle = new Circle(5f, Color.red);
Rectangle rectangle = new Rectangle(4f, 6f, Color.blue);

// Call methods
circle.Display();
Debug.Log($"Circle area: {circle.CalculateArea()}");

rectangle.Display();
Debug.Log($"Rectangle area: {rectangle.CalculateArea()}");

// Use polymorphism with the abstract base type
Shape shape1 = circle;
Shape shape2 = rectangle;

shape1.Display(); // Calls Circle.Display()
shape2.Display(); // Calls Rectangle.Display()

Abstract Classes vs. Interfaces

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

FeatureAbstract ClassInterface
InstantiationCannot be instantiatedCannot be instantiated
ImplementationCan provide implementation for some methodsCannot provide implementation (before C# 8.0)
InheritanceA class can inherit from only one abstract classA class can implement multiple interfaces
FieldsCan contain fieldsCannot contain fields (only properties)
Access ModifiersCan use access modifiersAll members are implicitly public
ConstructorCan have constructorsCannot have constructors
Static MembersCan have static membersCannot have static members

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

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

Abstract Classes in Unity

Unity uses abstract classes in its architecture. Here are some examples:

MonoBehaviour

While MonoBehaviour itself is not abstract, it inherits from Behaviour, which inherits from Component, which inherits from Object. This hierarchy uses abstract classes to define common functionality.

Custom Abstract Classes in Unity

You can create your own abstract classes in Unity to define common behavior for game objects:

// Abstract base class for all enemies
public abstract class Enemy : MonoBehaviour
{
[SerializeField] protected string enemyName;
[SerializeField] protected int maxHealth;
[SerializeField] protected float moveSpeed;

protected int currentHealth;
protected Transform player;

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

protected virtual void Update()
{
if (player != null)
{
Move();
}
}

// Abstract method - all enemies must implement their own movement
protected abstract void Move();

// Regular method with implementation
public virtual void TakeDamage(int damage)
{
currentHealth -= damage;
Debug.Log($"{enemyName} takes {damage} damage. Health: {currentHealth}/{maxHealth}");

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

// Virtual method with default implementation
protected virtual void Die()
{
Debug.Log($"{enemyName} has been defeated!");
Destroy(gameObject);
}

// Abstract method - all enemies must implement their own attack
public abstract void Attack();
}

// Concrete implementation for a melee enemy
public class MeleeEnemy : Enemy
{
[SerializeField] private float attackRange = 2f;
[SerializeField] private int attackDamage = 10;
[SerializeField] private float attackCooldown = 1.5f;

private float lastAttackTime;

protected override void Move()
{
if (player == null) return;

// Calculate distance to player
float distanceToPlayer = Vector3.Distance(transform.position, player.position);

// If within attack range, stop and attack
if (distanceToPlayer <= attackRange)
{
if (Time.time >= lastAttackTime + attackCooldown)
{
Attack();
}
}
else
{
// Move towards player
Vector3 direction = (player.position - transform.position).normalized;
transform.position += direction * moveSpeed * Time.deltaTime;
transform.rotation = Quaternion.LookRotation(direction);
}
}

public override void Attack()
{
Debug.Log($"{enemyName} performs a melee attack for {attackDamage} damage!");

// Check if player is within attack range
float distanceToPlayer = Vector3.Distance(transform.position, player.position);
if (distanceToPlayer <= attackRange)
{
// Apply damage to player
PlayerHealth playerHealth = player.GetComponent<PlayerHealth>();
if (playerHealth != null)
{
playerHealth.TakeDamage(attackDamage);
}
}

lastAttackTime = Time.time;
}

protected override void Die()
{
// Spawn some loot
Debug.Log($"{enemyName} drops some loot!");

// Call the base implementation
base.Die();
}
}

// Concrete implementation for a ranged enemy
public class RangedEnemy : Enemy
{
[SerializeField] private float attackRange = 10f;
[SerializeField] private int attackDamage = 5;
[SerializeField] private float attackCooldown = 2f;
[SerializeField] private GameObject projectilePrefab;
[SerializeField] private Transform firePoint;

private float lastAttackTime;

protected override void Move()
{
if (player == null) return;

// Calculate distance to player
float distanceToPlayer = Vector3.Distance(transform.position, player.position);

// If within attack range, stop and attack
if (distanceToPlayer <= attackRange && distanceToPlayer > attackRange / 2)
{
// Stay at optimal range
if (Time.time >= lastAttackTime + attackCooldown)
{
Attack();
}
}
else if (distanceToPlayer < attackRange / 2)
{
// Too close, move away from player
Vector3 direction = (transform.position - player.position).normalized;
transform.position += direction * moveSpeed * Time.deltaTime;
transform.rotation = Quaternion.LookRotation(-direction);
}
else
{
// Too far, move towards player
Vector3 direction = (player.position - transform.position).normalized;
transform.position += direction * moveSpeed * Time.deltaTime;
transform.rotation = Quaternion.LookRotation(direction);
}
}

public override void Attack()
{
Debug.Log($"{enemyName} fires a projectile for {attackDamage} damage!");

// Spawn projectile
if (projectilePrefab != null && firePoint != null)
{
GameObject projectile = Instantiate(projectilePrefab, firePoint.position, firePoint.rotation);

// Set up projectile
Projectile projectileComponent = projectile.GetComponent<Projectile>();
if (projectileComponent != null)
{
projectileComponent.Initialize(attackDamage, player.position - firePoint.position);
}
}

lastAttackTime = Time.time;
}
}

// Concrete implementation for a boss enemy
public class BossEnemy : Enemy
{
[SerializeField] private float phase1Health = 0.7f; // 70% health
[SerializeField] private float phase2Health = 0.3f; // 30% health
[SerializeField] private float attackRange = 5f;
[SerializeField] private int attackDamage = 20;
[SerializeField] private float attackCooldown = 3f;

private float lastAttackTime;
private int currentPhase = 1;

protected override void Awake()
{
base.Awake();
Debug.Log($"Boss {enemyName} has appeared!");
}

protected override void Move()
{
if (player == null) return;

// Movement behavior based on current phase
switch (currentPhase)
{
case 1:
MovePhase1();
break;
case 2:
MovePhase2();
break;
case 3:
MovePhase3();
break;
}
}

private void MovePhase1()
{
// Slow, direct approach
Vector3 direction = (player.position - transform.position).normalized;
transform.position += direction * (moveSpeed * 0.7f) * Time.deltaTime;
transform.rotation = Quaternion.LookRotation(direction);

// Attack if in range
float distanceToPlayer = Vector3.Distance(transform.position, player.position);
if (distanceToPlayer <= attackRange && Time.time >= lastAttackTime + attackCooldown)
{
Attack();
}
}

private void MovePhase2()
{
// Faster, more aggressive approach
Vector3 direction = (player.position - transform.position).normalized;
transform.position += direction * moveSpeed * Time.deltaTime;
transform.rotation = Quaternion.LookRotation(direction);

// Attack if in range with reduced cooldown
float distanceToPlayer = Vector3.Distance(transform.position, player.position);
if (distanceToPlayer <= attackRange && Time.time >= lastAttackTime + (attackCooldown * 0.7f))
{
Attack();
}
}

private void MovePhase3()
{
// Erratic, very aggressive approach
Vector3 direction = (player.position - transform.position).normalized;

// Add some randomness to movement
direction += new Vector3(
UnityEngine.Random.Range(-0.3f, 0.3f),
0,
UnityEngine.Random.Range(-0.3f, 0.3f)
).normalized;

transform.position += direction * (moveSpeed * 1.5f) * Time.deltaTime;
transform.rotation = Quaternion.LookRotation(direction);

// Attack if in range with greatly reduced cooldown
float distanceToPlayer = Vector3.Distance(transform.position, player.position);
if (distanceToPlayer <= attackRange && Time.time >= lastAttackTime + (attackCooldown * 0.4f))
{
Attack();
}
}

public override void Attack()
{
// Attack behavior based on current phase
switch (currentPhase)
{
case 1:
Debug.Log($"{enemyName} performs a basic attack for {attackDamage} damage!");
DealDamageToPlayer(attackDamage);
break;

case 2:
Debug.Log($"{enemyName} performs an enhanced attack for {attackDamage * 1.5f} damage!");
DealDamageToPlayer(Mathf.RoundToInt(attackDamage * 1.5f));
break;

case 3:
Debug.Log($"{enemyName} performs a desperate attack for {attackDamage * 2f} damage!");
DealDamageToPlayer(attackDamage * 2);

// Area effect in phase 3
Debug.Log($"{enemyName} creates a shockwave!");
// Implementation for shockwave effect...
break;
}

lastAttackTime = Time.time;
}

private void DealDamageToPlayer(int damage)
{
if (player == null) return;

PlayerHealth playerHealth = player.GetComponent<PlayerHealth>();
if (playerHealth != null)
{
playerHealth.TakeDamage(damage);
}
}

public override void TakeDamage(int damage)
{
base.TakeDamage(damage);

// Check for phase transitions
float healthPercentage = (float)currentHealth / maxHealth;

if (healthPercentage <= phase2Health && currentPhase < 3)
{
TransitionToPhase(3);
}
else if (healthPercentage <= phase1Health && currentPhase < 2)
{
TransitionToPhase(2);
}
}

private void TransitionToPhase(int newPhase)
{
currentPhase = newPhase;
Debug.Log($"{enemyName} enters phase {currentPhase}!");

// Visual effect for phase transition
// Implementation for phase transition effect...
}

protected override void Die()
{
Debug.Log($"Boss {enemyName} has been defeated! Victory!");

// Spawn special loot
Debug.Log($"{enemyName} drops rare loot!");

// Maybe trigger a cutscene or level completion
// ...

base.Die();
}
}

// Simple projectile class
public class Projectile : MonoBehaviour
{
private int damage;
private Vector3 direction;
private float speed = 10f;

public void Initialize(int damage, Vector3 direction)
{
this.damage = damage;
this.direction = direction.normalized;
}

private void Update()
{
// Move in the specified direction
transform.position += direction * speed * Time.deltaTime;

// Destroy after a certain time
Destroy(gameObject, 5f);
}

private void OnTriggerEnter(Collider other)
{
// Check if we hit the player
if (other.CompareTag("Player"))
{
PlayerHealth playerHealth = other.GetComponent<PlayerHealth>();
if (playerHealth != null)
{
playerHealth.TakeDamage(damage);
Debug.Log($"Projectile hit player for {damage} damage!");
}
}

// Destroy the projectile on impact
Destroy(gameObject);
}
}

// Simple player health class
public class PlayerHealth : MonoBehaviour
{
[SerializeField] private int maxHealth = 100;
private int currentHealth;

private void Awake()
{
currentHealth = maxHealth;
}

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

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

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

// Enemy spawner
public class EnemySpawner : MonoBehaviour
{
[SerializeField] private GameObject meleeEnemyPrefab;
[SerializeField] private GameObject rangedEnemyPrefab;
[SerializeField] private GameObject bossEnemyPrefab;
[SerializeField] private Transform[] spawnPoints;

public void SpawnMeleeEnemy()
{
SpawnEnemy(meleeEnemyPrefab);
}

public void SpawnRangedEnemy()
{
SpawnEnemy(rangedEnemyPrefab);
}

public void SpawnBossEnemy()
{
SpawnEnemy(bossEnemyPrefab);
}

private void SpawnEnemy(GameObject enemyPrefab)
{
if (enemyPrefab == null || spawnPoints.Length == 0) return;

// Choose a random spawn point
Transform spawnPoint = spawnPoints[UnityEngine.Random.Range(0, spawnPoints.Length)];

// Spawn the enemy
GameObject enemy = Instantiate(enemyPrefab, spawnPoint.position, spawnPoint.rotation);

Debug.Log($"Spawned {enemy.name} at {spawnPoint.name}");
}

// Polymorphic handling of enemies
public void ProcessEnemies()
{
// Find all enemies in the scene
Enemy[] enemies = FindObjectsOfType<Enemy>();

Debug.Log($"Processing {enemies.Length} enemies");

// Process each enemy polymorphically
foreach (Enemy enemy in enemies)
{
// We can call methods on the abstract base class
enemy.TakeDamage(1); // Just a small amount to demonstrate

// This will call the appropriate implementation based on the actual type
enemy.Attack();
}
}
}

In this example, we have an abstract Enemy class that defines common functionality for all enemies. It has abstract methods Move() and Attack() that derived classes must implement. We then have three concrete enemy types (MeleeEnemy, RangedEnemy, and BossEnemy) that provide their own implementations of these methods.

The EnemySpawner class demonstrates polymorphism by working with the abstract Enemy type, calling methods that will execute the appropriate implementation based on the actual object type.

More Examples of Abstract Classes

Example 1: Weapon System

// Abstract base class for all weapons
public abstract class Weapon : MonoBehaviour
{
[SerializeField] protected string weaponName;
[SerializeField] protected int damage;
[SerializeField] protected float attackSpeed;
[SerializeField] protected float durability;
[SerializeField] protected AudioClip attackSound;

protected AudioSource audioSource;
protected float lastAttackTime;

protected virtual void Awake()
{
audioSource = GetComponent<AudioSource>();
if (audioSource == null)
{
audioSource = gameObject.AddComponent<AudioSource>();
}
}

// Abstract method - all weapons must implement their own attack logic
public abstract void Attack();

// Regular method with implementation
public virtual bool CanAttack()
{
return Time.time >= lastAttackTime + (1f / attackSpeed);
}

// Regular method with implementation
protected virtual void PlayAttackSound()
{
if (attackSound != null && audioSource != null)
{
audioSource.PlayOneShot(attackSound);
}
}

// Regular method with implementation
public virtual void DecreaseDurability(float amount)
{
durability -= amount;
Debug.Log($"{weaponName} durability: {durability}");

if (durability <= 0)
{
Break();
}
}

// Virtual method with default implementation
protected virtual void Break()
{
Debug.Log($"{weaponName} has broken!");
Destroy(gameObject);
}

// Abstract method - all weapons must implement their own upgrade logic
public abstract void Upgrade();
}

// Concrete implementation for a melee weapon
public class MeleeWeapon : Weapon
{
[SerializeField] private float swingAngle = 90f;
[SerializeField] private float range = 2f;
[SerializeField] private LayerMask targetLayers;

public override void Attack()
{
if (!CanAttack()) return;

Debug.Log($"Swinging {weaponName} for {damage} damage");

// Play attack animation
// Implementation for attack animation...

// Play attack sound
PlayAttackSound();

// Detect hits
Collider[] hits = Physics.OverlapSphere(transform.position, range, targetLayers);

foreach (Collider hit in hits)
{
// Check if the hit is within the swing angle
Vector3 directionToTarget = (hit.transform.position - transform.position).normalized;
float angle = Vector3.Angle(transform.forward, directionToTarget);

if (angle <= swingAngle / 2)
{
// Apply damage
IDamageable target = hit.GetComponent<IDamageable>();
if (target != null)
{
target.TakeDamage(damage);
}
}
}

// Decrease durability
DecreaseDurability(0.5f);

// Update last attack time
lastAttackTime = Time.time;
}

public override void Upgrade()
{
damage += 5;
attackSpeed += 0.2f;
durability += 20;

Debug.Log($"{weaponName} upgraded! Damage: {damage}, Attack Speed: {attackSpeed}, Durability: {durability}");
}
}

// Concrete implementation for a ranged weapon
public class RangedWeapon : Weapon
{
[SerializeField] private GameObject projectilePrefab;
[SerializeField] private Transform firePoint;
[SerializeField] private float projectileSpeed = 20f;
[SerializeField] private int maxAmmo = 10;
[SerializeField] private float reloadTime = 1.5f;

private int currentAmmo;
private bool isReloading;

protected override void Awake()
{
base.Awake();
currentAmmo = maxAmmo;
}

public override bool CanAttack()
{
return base.CanAttack() && currentAmmo > 0 && !isReloading;
}

public override void Attack()
{
if (!CanAttack()) return;

Debug.Log($"Firing {weaponName} for {damage} damage");

// Play attack animation
// Implementation for attack animation...

// Play attack sound
PlayAttackSound();

// Spawn projectile
if (projectilePrefab != null && firePoint != null)
{
GameObject projectile = Instantiate(projectilePrefab, firePoint.position, firePoint.rotation);

// Set up projectile
Projectile projectileComponent = projectile.GetComponent<Projectile>();
if (projectileComponent != null)
{
projectileComponent.Initialize(damage, firePoint.forward * projectileSpeed);
}
else
{
// If no Projectile component, just add force to the rigidbody
Rigidbody rb = projectile.GetComponent<Rigidbody>();
if (rb != null)
{
rb.velocity = firePoint.forward * projectileSpeed;
}
}
}

// Decrease ammo
currentAmmo--;

// Decrease durability
DecreaseDurability(0.2f);

// Update last attack time
lastAttackTime = Time.time;

// Auto-reload if out of ammo
if (currentAmmo <= 0)
{
StartCoroutine(Reload());
}
}

private IEnumerator Reload()
{
isReloading = true;
Debug.Log($"Reloading {weaponName}...");

// Play reload animation
// Implementation for reload animation...

// Wait for reload time
yield return new WaitForSeconds(reloadTime);

// Refill ammo
currentAmmo = maxAmmo;

isReloading = false;
Debug.Log($"{weaponName} reloaded. Ammo: {currentAmmo}/{maxAmmo}");
}

public override void Upgrade()
{
damage += 3;
maxAmmo += 5;
reloadTime *= 0.8f;
durability += 15;

// Refill ammo on upgrade
currentAmmo = maxAmmo;

Debug.Log($"{weaponName} upgraded! Damage: {damage}, Max Ammo: {maxAmmo}, Reload Time: {reloadTime}, Durability: {durability}");
}
}

// Concrete implementation for a magic weapon
public class MagicWeapon : Weapon
{
[SerializeField] private int manaCost = 10;
[SerializeField] private float areaOfEffect = 5f;
[SerializeField] private GameObject spellEffectPrefab;
[SerializeField] private LayerMask targetLayers;
[SerializeField] private StatusEffect statusEffect;
[SerializeField] private float statusEffectDuration = 3f;

private PlayerMana playerMana;

protected override void Awake()
{
base.Awake();

// Find player mana component
playerMana = FindObjectOfType<PlayerMana>();
}

public override bool CanAttack()
{
return base.CanAttack() && playerMana != null && playerMana.CurrentMana >= manaCost;
}

public override void Attack()
{
if (!CanAttack()) return;

Debug.Log($"Casting {weaponName} for {damage} damage");

// Play attack animation
// Implementation for attack animation...

// Play attack sound
PlayAttackSound();

// Consume mana
playerMana.UseMana(manaCost);

// Spawn spell effect
if (spellEffectPrefab != null)
{
Instantiate(spellEffectPrefab, transform.position, transform.rotation);
}

// Apply area damage
Collider[] hits = Physics.OverlapSphere(transform.position, areaOfEffect, targetLayers);

foreach (Collider hit in hits)
{
// Apply damage
IDamageable target = hit.GetComponent<IDamageable>();
if (target != null)
{
// Calculate damage falloff based on distance
float distance = Vector3.Distance(transform.position, hit.transform.position);
float damageMultiplier = 1f - (distance / areaOfEffect);
int adjustedDamage = Mathf.RoundToInt(damage * damageMultiplier);

target.TakeDamage(adjustedDamage);

// Apply status effect
IStatusEffectable statusTarget = hit.GetComponent<IStatusEffectable>();
if (statusTarget != null && statusEffect != StatusEffect.None)
{
statusTarget.ApplyStatusEffect(statusEffect, statusEffectDuration);
}
}
}

// Decrease durability
DecreaseDurability(0.3f);

// Update last attack time
lastAttackTime = Time.time;
}

public override void Upgrade()
{
damage += 7;
manaCost -= 2;
areaOfEffect += 1f;
statusEffectDuration += 1f;
durability += 10;

Debug.Log($"{weaponName} upgraded! Damage: {damage}, Mana Cost: {manaCost}, Area: {areaOfEffect}, Effect Duration: {statusEffectDuration}, Durability: {durability}");
}
}

// Interface for objects that can take damage
public interface IDamageable
{
void TakeDamage(int amount);
}

// Interface for objects that can have status effects
public interface IStatusEffectable
{
void ApplyStatusEffect(StatusEffect effect, float duration);
}

// Status effect enum
public enum StatusEffect
{
None,
Burn,
Freeze,
Poison,
Stun
}

// Player mana component
public class PlayerMana : MonoBehaviour
{
[SerializeField] private int maxMana = 100;
private int currentMana;

public int CurrentMana => currentMana;
public int MaxMana => maxMana;

private void Awake()
{
currentMana = maxMana;
}

public bool UseMana(int amount)
{
if (currentMana >= amount)
{
currentMana -= amount;
return true;
}

return false;
}

public void RestoreMana(int amount)
{
currentMana = Mathf.Min(currentMana + amount, maxMana);
}
}

// Weapon manager
public class WeaponManager : MonoBehaviour
{
[SerializeField] private Transform weaponSocket;
private Weapon currentWeapon;

public void EquipWeapon(Weapon newWeapon)
{
// Unequip current weapon
if (currentWeapon != null)
{
Destroy(currentWeapon.gameObject);
}

// Equip new weapon
if (newWeapon != null && weaponSocket != null)
{
currentWeapon = Instantiate(newWeapon, weaponSocket);
Debug.Log($"Equipped {currentWeapon.GetType().Name}");
}
}

public void Attack()
{
if (currentWeapon != null)
{
currentWeapon.Attack();
}
}

public void UpgradeWeapon()
{
if (currentWeapon != null)
{
currentWeapon.Upgrade();
}
}
}

In this example, we have an abstract Weapon class that defines common functionality for all weapons. It has abstract methods Attack() and Upgrade() that derived classes must implement. We then have three concrete weapon types (MeleeWeapon, RangedWeapon, and MagicWeapon) that provide their own implementations of these methods.

The WeaponManager class demonstrates polymorphism by working with the abstract Weapon type, calling methods that will execute the appropriate implementation based on the actual object type.

Example 2: Power-Up System

// Abstract base class for all power-ups
public abstract class PowerUp : MonoBehaviour
{
[SerializeField] protected string powerUpName;
[SerializeField] protected float duration;
[SerializeField] protected Sprite icon;
[SerializeField] protected GameObject visualEffect;
[SerializeField] protected AudioClip pickupSound;

protected bool isActive = false;
protected float activationTime;

public string PowerUpName => powerUpName;
public float Duration => duration;
public Sprite Icon => icon;
public bool IsActive => isActive;
public float RemainingTime => isActive ? Mathf.Max(0, (activationTime + duration) - Time.time) : 0;

// Abstract method - all power-ups must implement their own activation logic
public abstract void Activate(GameObject target);

// Abstract method - all power-ups must implement their own deactivation logic
public abstract void Deactivate(GameObject target);

// Regular method with implementation
public virtual void Pickup(GameObject target)
{
Debug.Log($"{target.name} picked up {powerUpName}");

// Play pickup sound
if (pickupSound != null)
{
AudioSource.PlayClipAtPoint(pickupSound, transform.position);
}

// Spawn visual effect
if (visualEffect != null)
{
Instantiate(visualEffect, target.transform.position, Quaternion.identity, target.transform);
}

// Activate the power-up
Activate(target);

// Set active state
isActive = true;
activationTime = Time.time;

// Start deactivation timer
StartCoroutine(DeactivationTimer(target));

// Hide the pickup object
GetComponent<Renderer>().enabled = false;
GetComponent<Collider>().enabled = false;
}

// Regular method with implementation
protected virtual IEnumerator DeactivationTimer(GameObject target)
{
yield return new WaitForSeconds(duration);

if (isActive)
{
Deactivate(target);
isActive = false;

Debug.Log($"{powerUpName} effect ended for {target.name}");
}

// Destroy the power-up object
Destroy(gameObject);
}

// Virtual method with default implementation
public virtual string GetDescription()
{
return $"{powerUpName}: Lasts for {duration} seconds";
}
}

// Concrete implementation for a speed boost power-up
public class SpeedBoostPowerUp : PowerUp
{
[SerializeField] private float speedMultiplier = 2f;

private PlayerMovement playerMovement;

public override void Activate(GameObject target)
{
// Find the player movement component
playerMovement = target.GetComponent<PlayerMovement>();

if (playerMovement != null)
{
// Apply speed boost
playerMovement.ApplySpeedMultiplier(speedMultiplier);

Debug.Log($"Speed boost activated! Speed multiplier: {speedMultiplier}");
}
}

public override void Deactivate(GameObject target)
{
if (playerMovement != null)
{
// Remove speed boost
playerMovement.RemoveSpeedMultiplier(speedMultiplier);
}
}

public override string GetDescription()
{
return $"{base.GetDescription()}\nIncreases movement speed by {(speedMultiplier - 1) * 100}%";
}
}

// Concrete implementation for an invincibility power-up
public class InvincibilityPowerUp : PowerUp
{
private PlayerHealth playerHealth;

public override void Activate(GameObject target)
{
// Find the player health component
playerHealth = target.GetComponent<PlayerHealth>();

if (playerHealth != null)
{
// Make player invincible
playerHealth.SetInvincible(true);

Debug.Log("Invincibility activated!");
}
}

public override void Deactivate(GameObject target)
{
if (playerHealth != null)
{
// Remove invincibility
playerHealth.SetInvincible(false);
}
}

public override string GetDescription()
{
return $"{base.GetDescription()}\nMakes you invulnerable to all damage";
}
}

// Concrete implementation for a weapon power-up
public class WeaponPowerUp : PowerUp
{
[SerializeField] private float damageMultiplier = 1.5f;

private PlayerWeapon playerWeapon;

public override void Activate(GameObject target)
{
// Find the player weapon component
playerWeapon = target.GetComponent<PlayerWeapon>();

if (playerWeapon != null)
{
// Apply damage boost
playerWeapon.ApplyDamageMultiplier(damageMultiplier);

Debug.Log($"Weapon power-up activated! Damage multiplier: {damageMultiplier}");
}
}

public override void Deactivate(GameObject target)
{
if (playerWeapon != null)
{
// Remove damage boost
playerWeapon.RemoveDamageMultiplier(damageMultiplier);
}
}

public override string GetDescription()
{
return $"{base.GetDescription()}\nIncreases weapon damage by {(damageMultiplier - 1) * 100}%";
}
}

// Player movement component
public class PlayerMovement : MonoBehaviour
{
[SerializeField] private float baseSpeed = 5f;
private float currentSpeedMultiplier = 1f;

public float CurrentSpeed => baseSpeed * currentSpeedMultiplier;

public void ApplySpeedMultiplier(float multiplier)
{
currentSpeedMultiplier *= multiplier;
Debug.Log($"Speed multiplier applied. Current speed: {CurrentSpeed}");
}

public void RemoveSpeedMultiplier(float multiplier)
{
currentSpeedMultiplier /= multiplier;
Debug.Log($"Speed multiplier removed. Current speed: {CurrentSpeed}");
}

private void Update()
{
// Movement implementation using CurrentSpeed
// ...
}
}

// Extended player health class
public class PlayerHealth : MonoBehaviour, IDamageable
{
[SerializeField] private int maxHealth = 100;
private int currentHealth;
private bool isInvincible = false;

private void Awake()
{
currentHealth = maxHealth;
}

public void TakeDamage(int damage)
{
if (isInvincible)
{
Debug.Log("Player is invincible! No damage taken.");
return;
}

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

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

public void SetInvincible(bool invincible)
{
isInvincible = invincible;
Debug.Log($"Player invincibility set to: {isInvincible}");
}

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

// Player weapon component
public class PlayerWeapon : MonoBehaviour
{
[SerializeField] private int baseDamage = 10;
private float currentDamageMultiplier = 1f;

public int CurrentDamage => Mathf.RoundToInt(baseDamage * currentDamageMultiplier);

public void ApplyDamageMultiplier(float multiplier)
{
currentDamageMultiplier *= multiplier;
Debug.Log($"Damage multiplier applied. Current damage: {CurrentDamage}");
}

public void RemoveDamageMultiplier(float multiplier)
{
currentDamageMultiplier /= multiplier;
Debug.Log($"Damage multiplier removed. Current damage: {CurrentDamage}");
}

public void Attack(IDamageable target)
{
if (target != null)
{
target.TakeDamage(CurrentDamage);
Debug.Log($"Attacked target for {CurrentDamage} damage");
}
}
}

// Power-up manager
public class PowerUpManager : MonoBehaviour
{
[SerializeField] private GameObject player;
[SerializeField] private Transform powerUpContainer;
[SerializeField] private PowerUp[] powerUpPrefabs;
[SerializeField] private float spawnInterval = 30f;
[SerializeField] private Transform[] spawnPoints;

private List<PowerUp> activePowerUps = new List<PowerUp>();
private float lastSpawnTime;

private void Start()
{
lastSpawnTime = Time.time;
}

private void Update()
{
// Spawn power-ups periodically
if (Time.time >= lastSpawnTime + spawnInterval)
{
SpawnRandomPowerUp();
lastSpawnTime = Time.time;
}

// Update UI for active power-ups
UpdatePowerUpUI();
}

private void SpawnRandomPowerUp()
{
if (powerUpPrefabs.Length == 0 || spawnPoints.Length == 0) return;

// Choose a random power-up
PowerUp powerUpPrefab = powerUpPrefabs[UnityEngine.Random.Range(0, powerUpPrefabs.Length)];

// Choose a random spawn point
Transform spawnPoint = spawnPoints[UnityEngine.Random.Range(0, spawnPoints.Length)];

// Spawn the power-up
PowerUp powerUp = Instantiate(powerUpPrefab, spawnPoint.position, Quaternion.identity, powerUpContainer);

Debug.Log($"Spawned {powerUp.PowerUpName} at {spawnPoint.name}");
}

private void UpdatePowerUpUI()
{
// Update UI for active power-ups
// This would be implemented based on your UI system
// ...
}

// Called by collision detection on the player
public void OnPowerUpCollected(PowerUp powerUp)
{
if (player != null && powerUp != null)
{
// Activate the power-up
powerUp.Pickup(player);

// Add to active power-ups list
activePowerUps.Add(powerUp);

// Remove from list when deactivated
StartCoroutine(RemoveFromActiveList(powerUp));
}
}

private IEnumerator RemoveFromActiveList(PowerUp powerUp)
{
yield return new WaitForSeconds(powerUp.Duration);
activePowerUps.Remove(powerUp);
}
}

In this example, we have an abstract PowerUp class that defines common functionality for all power-ups. It has abstract methods Activate() and Deactivate() that derived classes must implement. We then have three concrete power-up types (SpeedBoostPowerUp, InvincibilityPowerUp, and WeaponPowerUp) that provide their own implementations of these methods.

The PowerUpManager class demonstrates polymorphism by working with the abstract PowerUp type, calling methods that will execute the appropriate implementation based on the actual object type.

Best Practices for Abstract Classes

  1. Use Abstract Classes for "Is-A" Relationships: Abstract classes are best used when there's a clear "is-a" relationship between the abstract class and its derived classes.

  2. Keep the Inheritance Hierarchy Shallow: Deep inheritance hierarchies can become difficult to understand and maintain. Try to keep it to 3-4 levels at most.

  3. Design Abstract Classes Carefully: Think about what functionality should be common to all derived classes and what should be specific to each derived class.

  4. Use Abstract Methods for Required Functionality: Use abstract methods for functionality that must be implemented by all derived classes, but where the implementation will vary.

  5. Provide Default Implementations When Possible: Use virtual methods with default implementations for functionality that might be common to most derived classes but could be overridden if needed.

  6. Document Abstract Methods Clearly: Clearly document the intended behavior of abstract methods so derived classes know how to implement them correctly.

  7. Consider Interfaces for Multiple Inheritance: When you need functionality from multiple sources, use interfaces instead of trying to simulate multiple inheritance.

  8. Make Fields Protected, Not Private: Fields that derived classes need to access should be declared as protected, not private.

  9. Use Abstract Properties for Required State: Use abstract properties for state that must be provided by all derived classes.

  10. Avoid Constructors with Complex Logic: Keep constructors simple, as they will be called by derived classes.

Conclusion

Abstract classes and methods are powerful tools for designing robust class hierarchies in C#. They allow you to:

  • Define common functionality for related classes
  • Enforce implementation requirements for derived classes
  • Create a clear contract that derived classes must follow
  • Provide default implementations for common behavior
  • Enable polymorphic behavior across a family of classes

In Unity, abstract classes are particularly useful for creating game systems where you have a family of related objects with shared behavior but different implementations. By using abstract classes effectively, you can create more organized, maintainable, and extensible code for your games.

In the next section, we'll explore sealed classes and methods, which are the opposite of abstract classes and methods in that they prevent inheritance and overriding.

Practice Exercise

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

  1. Create an abstract Item class with:

    • Properties for name, description, value, and weight
    • An abstract method for using the item
    • A virtual method for displaying item information
  2. Create at least three derived item types (e.g., ConsumableItem, EquipmentItem, QuestItem) that:

    • Implement the abstract method for using the item
    • Add at least one unique property or method to each
    • Override the virtual method for displaying item information if needed
  3. Create a simple Inventory class that can store and manage items.

  4. Demonstrate polymorphism by creating an array or list of the abstract Item type and having the inventory use different items without knowing their specific types.

Think about how abstract classes help you enforce a consistent structure while allowing for specialized behavior in each item type.