Skip to main content

5.9 - Polymorphism

Polymorphism is one of the four pillars of Object-Oriented Programming (along with encapsulation, abstraction, and inheritance). The word "polymorphism" comes from Greek words meaning "many forms," and it refers to the ability of objects of different classes to be treated as objects of a common base class.

What is Polymorphism?

Polymorphism allows you to write code that can work with objects of different types through a common interface. It enables you to:

  1. Treat objects of derived classes as objects of their base class
  2. Call methods on these objects that will execute the appropriate implementation based on the actual object type
  3. Write more flexible and extensible code that can handle new derived classes without modification

There are two main types of polymorphism in C#:

  • Compile-time polymorphism (method overloading)
  • Runtime polymorphism (method overriding)

We've already covered method overloading in previous sections, so we'll focus on runtime polymorphism here.

Runtime Polymorphism

Runtime polymorphism is achieved through method overriding, which we introduced in the inheritance section. It allows a derived class to provide a specific implementation of a method that is already defined in its base class.

Here's a simple example:

public class Shape
{
public virtual void Draw()
{
Console.WriteLine("Drawing a shape");
}
}

public class Circle : Shape
{
public override void Draw()
{
Console.WriteLine("Drawing a circle");
}
}

public class Rectangle : Shape
{
public override void Draw()
{
Console.WriteLine("Drawing a rectangle");
}
}

// Usage
Shape shape1 = new Shape();
Shape shape2 = new Circle(); // A Circle reference stored in a Shape variable
Shape shape3 = new Rectangle(); // A Rectangle reference stored in a Shape variable

shape1.Draw(); // Output: Drawing a shape
shape2.Draw(); // Output: Drawing a circle
shape3.Draw(); // Output: Drawing a rectangle

In this example, even though shape2 and shape3 are declared as Shape variables, they actually contain Circle and Rectangle objects. When we call the Draw() method, the appropriate implementation is called based on the actual object type, not the variable type. This is runtime polymorphism in action.

Key Components of Polymorphism

1. Virtual Methods

A virtual method is a method that can be overridden in a derived class. In C#, you use the virtual keyword to declare a method as virtual:

public class Enemy
{
public virtual void Attack()
{
Console.WriteLine("Enemy attacks!");
}
}

2. Override Methods

An override method provides a new implementation of a virtual method defined in a base class. In C#, you use the override keyword to override a method:

public class Goblin : Enemy
{
public override void Attack()
{
Console.WriteLine("Goblin attacks with a club!");
}
}

public class Dragon : Enemy
{
public override void Attack()
{
Console.WriteLine("Dragon breathes fire!");
}
}

3. Base Class References

Polymorphism allows you to use a base class reference to refer to a derived class object:

Enemy enemy1 = new Goblin();
Enemy enemy2 = new Dragon();

enemy1.Attack(); // Output: Goblin attacks with a club!
enemy2.Attack(); // Output: Dragon breathes fire!

This is powerful because it allows you to write code that works with any type of Enemy without knowing the specific derived type.

Benefits of Polymorphism

Polymorphism provides several key benefits:

  1. Code Reusability: You can reuse code that works with base class objects to work with derived class objects.

  2. Extensibility: You can add new derived classes without modifying existing code that works with the base class.

  3. Flexibility: You can write algorithms that work with a base class and automatically work with all derived classes.

  4. Abstraction: You can focus on what an object does rather than how it does it.

Polymorphism in Action

Let's look at a more complete example to see polymorphism in action:

public class Character
{
public string Name { get; protected set; }
public int Health { get; protected set; }

public Character(string name, int health)
{
Name = name;
Health = health;
}

public virtual void Attack(Character target)
{
Console.WriteLine($"{Name} attacks {target.Name} for 10 damage!");
target.TakeDamage(10);
}

public virtual void TakeDamage(int amount)
{
Health -= amount;
Console.WriteLine($"{Name} takes {amount} damage. Health: {Health}");

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

protected virtual void Die()
{
Console.WriteLine($"{Name} has been defeated!");
}
}

public class Warrior : Character
{
public int Strength { get; private set; }

public Warrior(string name, int health, int strength) : base(name, health)
{
Strength = strength;
}

public override void Attack(Character target)
{
int damage = 5 + Strength;
Console.WriteLine($"{Name} swings a sword at {target.Name} for {damage} damage!");
target.TakeDamage(damage);
}
}

public class Mage : Character
{
public int Mana { get; private set; }

public Mage(string name, int health, int mana) : base(name, health)
{
Mana = mana;
}

public override void Attack(Character target)
{
if (Mana >= 10)
{
Mana -= 10;
int damage = 20;
Console.WriteLine($"{Name} casts a fireball at {target.Name} for {damage} damage! Mana: {Mana}");
target.TakeDamage(damage);
}
else
{
Console.WriteLine($"{Name} is out of mana and cannot cast spells!");
base.Attack(target); // Fall back to basic attack
}
}

protected override void Die()
{
Console.WriteLine($"{Name} explodes in a burst of magical energy!");
base.Die();
}
}

public class Rogue : Character
{
public int Dexterity { get; private set; }

public Rogue(string name, int health, int dexterity) : base(name, health)
{
Dexterity = dexterity;
}

public override void Attack(Character target)
{
// Rogues have a chance to perform a critical hit
bool isCritical = UnityEngine.Random.Range(0, 100) < Dexterity;
int damage = isCritical ? 25 : 15;

Console.WriteLine($"{Name} strikes {target.Name} with a dagger for {damage} damage!" +
(isCritical ? " Critical hit!" : ""));

target.TakeDamage(damage);
}

public override void TakeDamage(int amount)
{
// Rogues have a chance to dodge attacks
bool isDodged = UnityEngine.Random.Range(0, 100) < Dexterity / 2;

if (isDodged)
{
Console.WriteLine($"{Name} dodges the attack!");
}
else
{
base.TakeDamage(amount);
}
}
}

// Usage
public class Battle
{
public static void SimulateBattle()
{
// Create characters
Character warrior = new Warrior("Conan", 150, 15);
Character mage = new Mage("Gandalf", 80, 100);
Character rogue = new Rogue("Bilbo", 100, 20);

// List of characters (polymorphism)
List<Character> characters = new List<Character> { warrior, mage, rogue };

// Simulate some attacks
warrior.Attack(mage); // Conan swings a sword at Gandalf for 20 damage!
mage.Attack(warrior); // Gandalf casts a fireball at Conan for 20 damage!
rogue.Attack(mage); // Bilbo strikes Gandalf with a dagger for 15 damage!

// Iterate through characters polymorphically
foreach (Character character in characters)
{
Console.WriteLine($"{character.Name} has {character.Health} health remaining.");

// We can call Attack() on any character without knowing its specific type
character.Attack(characters[UnityEngine.Random.Range(0, characters.Count)]);
}
}
}

In this example, we have a base Character class and three derived classes: Warrior, Mage, and Rogue. Each derived class overrides the Attack() method to provide its own implementation.

The key polymorphic behavior is in the SimulateBattle() method, where we:

  1. Store different character types in a list of the base Character type
  2. Iterate through the list and call Attack() on each character
  3. The appropriate implementation is called based on the actual object type

Polymorphism with Arrays and Collections

One of the most common uses of polymorphism is with arrays and collections of objects:

// Create an array of the base type
Enemy[] enemies = new Enemy[3];
enemies[0] = new Goblin();
enemies[1] = new Orc();
enemies[2] = new Dragon();

// Process all enemies polymorphically
foreach (Enemy enemy in enemies)
{
enemy.Attack(); // Calls the appropriate Attack() method for each enemy type
}

// Same with a List
List<Enemy> enemyList = new List<Enemy>();
enemyList.Add(new Goblin());
enemyList.Add(new Orc());
enemyList.Add(new Dragon());

foreach (Enemy enemy in enemyList)
{
enemy.Attack(); // Polymorphic behavior
}

This approach allows you to process a collection of different object types uniformly, which is extremely useful for game development.

Polymorphism and Type Checking

Sometimes you need to know the actual type of an object at runtime. C# provides several ways to do this:

1. is Operator

The is operator checks if an object is of a specific type:

public void ProcessEnemy(Enemy enemy)
{
if (enemy is Dragon)
{
Console.WriteLine("Watch out! It's a dragon!");
}

enemy.Attack(); // Polymorphic call
}

2. as Operator

The as operator attempts to cast an object to a specific type, returning null if the cast fails:

public void ProcessEnemy(Enemy enemy)
{
Dragon dragon = enemy as Dragon;

if (dragon != null)
{
// It's a dragon, we can use dragon-specific methods
dragon.BreatheFire();
}
else
{
// It's not a dragon
enemy.Attack();
}
}

3. Pattern Matching (C# 7.0+)

C# 7.0 introduced pattern matching, which provides a more elegant way to check and cast types:

public void ProcessEnemy(Enemy enemy)
{
switch (enemy)
{
case Dragon dragon:
Console.WriteLine("It's a dragon!");
dragon.BreatheFire();
break;

case Orc orc:
Console.WriteLine("It's an orc!");
orc.BerserkRage();
break;

case Goblin goblin:
Console.WriteLine("It's a goblin!");
goblin.Sneak();
break;

default:
Console.WriteLine("Unknown enemy type");
enemy.Attack();
break;
}
}

4. GetType() Method

The GetType() method returns the exact runtime type of an object:

public void ProcessEnemy(Enemy enemy)
{
Type enemyType = enemy.GetType();

if (enemyType == typeof(Dragon))
{
Console.WriteLine("It's a dragon!");
((Dragon)enemy).BreatheFire();
}
else
{
Console.WriteLine($"It's a {enemyType.Name}");
enemy.Attack();
}
}

While these type-checking techniques are useful, they should be used sparingly. Excessive type checking can defeat the purpose of polymorphism and lead to brittle code. It's often better to use polymorphic methods when possible.

Polymorphism in Unity

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

1. Component System

Unity's component system is built on polymorphism. The GetComponent<T>() method is a great example:

// Get any component that derives from Collider
Collider collider = gameObject.GetComponent<Collider>();

// This works with BoxCollider, SphereCollider, CapsuleCollider, etc.
// because they all inherit from Collider

2. MonoBehaviour Methods

Unity's message methods like Update(), Start(), and OnCollisionEnter() are all examples of polymorphism:

public class Enemy : MonoBehaviour
{
protected virtual void Update()
{
// Base enemy behavior
}
}

public class Zombie : Enemy
{
protected override void Update()
{
base.Update(); // Call base behavior
// Zombie-specific behavior
}
}

public class Ghost : Enemy
{
protected override void Update()
{
base.Update(); // Call base behavior
// Ghost-specific behavior
}
}

Unity will call the appropriate Update() method for each object based on its actual type.

3. Event Systems

Unity's event systems use polymorphism to handle different types of events:

public class UIManager : MonoBehaviour, IPointerClickHandler, IPointerEnterHandler
{
public void OnPointerClick(PointerEventData eventData)
{
Debug.Log("Clicked!");
}

public void OnPointerEnter(PointerEventData eventData)
{
Debug.Log("Mouse entered!");
}
}

Practical Examples

Example 1: Damage System

// Base damage class
public abstract class Damage
{
public float Amount { get; protected set; }

public Damage(float amount)
{
Amount = amount;
}

public abstract void Apply(GameObject target);

public virtual string GetDescription()
{
return $"{Amount} damage";
}
}

// Physical damage
public class PhysicalDamage : Damage
{
public float ArmorPenetration { get; private set; }

public PhysicalDamage(float amount, float armorPenetration) : base(amount)
{
ArmorPenetration = armorPenetration;
}

public override void Apply(GameObject target)
{
Health health = target.GetComponent<Health>();
Armor armor = target.GetComponent<Armor>();

if (health != null)
{
float effectiveArmor = armor != null ? Mathf.Max(0, armor.Value - ArmorPenetration) : 0;
float damageReduction = effectiveArmor / (effectiveArmor + 100); // Simple armor formula
float finalDamage = Amount * (1 - damageReduction);

health.TakeDamage(finalDamage);

Debug.Log($"Applied {finalDamage:F1} physical damage to {target.name} " +
$"(Reduced by {damageReduction:P0} from armor)");
}
}

public override string GetDescription()
{
return $"{Amount:F1} physical damage with {ArmorPenetration:F1} armor penetration";
}
}

// Fire damage
public class FireDamage : Damage
{
public float BurnDuration { get; private set; }
public float BurnDamagePerSecond { get; private set; }

public FireDamage(float amount, float burnDuration, float burnDamagePerSecond) : base(amount)
{
BurnDuration = burnDuration;
BurnDamagePerSecond = burnDamagePerSecond;
}

public override void Apply(GameObject target)
{
Health health = target.GetComponent<Health>();

if (health != null)
{
// Apply initial damage
health.TakeDamage(Amount);

// Apply burn effect
BurnEffect burnEffect = target.GetComponent<BurnEffect>();
if (burnEffect == null)
{
burnEffect = target.AddComponent<BurnEffect>();
}

burnEffect.ApplyBurn(BurnDuration, BurnDamagePerSecond);

Debug.Log($"Applied {Amount:F1} fire damage to {target.name} " +
$"and set on fire for {BurnDuration:F1} seconds");
}
}

public override string GetDescription()
{
return $"{Amount:F1} fire damage plus {BurnDamagePerSecond:F1} burn damage " +
$"per second for {BurnDuration:F1} seconds";
}
}

// Ice damage
public class IceDamage : Damage
{
public float SlowAmount { get; private set; }
public float SlowDuration { get; private set; }

public IceDamage(float amount, float slowAmount, float slowDuration) : base(amount)
{
SlowAmount = slowAmount;
SlowDuration = slowDuration;
}

public override void Apply(GameObject target)
{
Health health = target.GetComponent<Health>();
Movement movement = target.GetComponent<Movement>();

if (health != null)
{
// Apply damage
health.TakeDamage(Amount);

// Apply slow effect
if (movement != null)
{
movement.ApplySlow(SlowAmount, SlowDuration);
}

Debug.Log($"Applied {Amount:F1} ice damage to {target.name} " +
$"and slowed by {SlowAmount:P0} for {SlowDuration:F1} seconds");
}
}

public override string GetDescription()
{
return $"{Amount:F1} ice damage with {SlowAmount:P0} slow for {SlowDuration:F1} seconds";
}
}

// Health component
public class Health : MonoBehaviour
{
[SerializeField] private float maxHealth = 100f;
private float currentHealth;

private void Awake()
{
currentHealth = maxHealth;
}

public void TakeDamage(float amount)
{
currentHealth -= amount;

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

private void Die()
{
Debug.Log($"{gameObject.name} has died!");
// Handle death...
}
}

// Armor component
public class Armor : MonoBehaviour
{
[SerializeField] private float armorValue = 50f;

public float Value => armorValue;
}

// Movement component
public class Movement : MonoBehaviour
{
[SerializeField] private float moveSpeed = 5f;
private float currentSpeedMultiplier = 1f;
private Coroutine slowCoroutine;

public void ApplySlow(float slowAmount, float duration)
{
// Cancel any existing slow effect
if (slowCoroutine != null)
{
StopCoroutine(slowCoroutine);
}

// Apply new slow effect
slowCoroutine = StartCoroutine(SlowCoroutine(slowAmount, duration));
}

private IEnumerator SlowCoroutine(float slowAmount, float duration)
{
// Apply slow
currentSpeedMultiplier = 1f - slowAmount;

// Wait for duration
yield return new WaitForSeconds(duration);

// Remove slow
currentSpeedMultiplier = 1f;
slowCoroutine = null;
}

private void Update()
{
// Movement implementation using currentSpeedMultiplier * moveSpeed
// ...
}
}

// Burn effect component
public class BurnEffect : MonoBehaviour
{
private float burnDamagePerSecond;
private float remainingDuration;
private Health health;

private void Awake()
{
health = GetComponent<Health>();
}

public void ApplyBurn(float duration, float damagePerSecond)
{
burnDamagePerSecond = damagePerSecond;
remainingDuration = duration;

// Make sure the Update method is running
enabled = true;
}

private void Update()
{
if (remainingDuration > 0)
{
// Apply burn damage
float damageThisFrame = burnDamagePerSecond * Time.deltaTime;
health.TakeDamage(damageThisFrame);

// Reduce remaining duration
remainingDuration -= Time.deltaTime;

// Visual effect (would be implemented with particles)
// ...
}
else
{
// Burn effect has ended
enabled = false;
}
}
}

// Weapon that can deal different types of damage
public class Weapon : MonoBehaviour
{
[SerializeField] private string weaponName;
[SerializeField] private DamageType damageType;
[SerializeField] private float baseDamage = 10f;

// Additional parameters for specific damage types
[SerializeField] private float armorPenetration = 20f;
[SerializeField] private float burnDuration = 3f;
[SerializeField] private float burnDamagePerSecond = 5f;
[SerializeField] private float slowAmount = 0.5f;
[SerializeField] private float slowDuration = 2f;

public enum DamageType
{
Physical,
Fire,
Ice
}

public void Attack(GameObject target)
{
// Create the appropriate damage type
Damage damage = CreateDamage();

// Apply the damage
damage.Apply(target);

Debug.Log($"{weaponName} dealt {damage.GetDescription()} to {target.name}");
}

private Damage CreateDamage()
{
switch (damageType)
{
case DamageType.Physical:
return new PhysicalDamage(baseDamage, armorPenetration);

case DamageType.Fire:
return new FireDamage(baseDamage, burnDuration, burnDamagePerSecond);

case DamageType.Ice:
return new IceDamage(baseDamage, slowAmount, slowDuration);

default:
return new PhysicalDamage(baseDamage, 0);
}
}
}

// Usage
public class Player : MonoBehaviour
{
[SerializeField] private Weapon[] weapons;
private int currentWeaponIndex = 0;

private void Update()
{
// Switch weapons
if (Input.GetKeyDown(KeyCode.Q))
{
currentWeaponIndex = (currentWeaponIndex + 1) % weapons.Length;
Debug.Log($"Switched to {weapons[currentWeaponIndex].name}");
}

// Attack
if (Input.GetMouseButtonDown(0))
{
// Raycast to find target
Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);
RaycastHit hit;

if (Physics.Raycast(ray, out hit))
{
// Attack the hit object
weapons[currentWeaponIndex].Attack(hit.collider.gameObject);
}
}
}
}

In this example, we have a polymorphic damage system with different types of damage (physical, fire, ice) that all inherit from a base Damage class. Each damage type has its own implementation of the Apply() method, which handles how that specific type of damage affects a target.

The Weapon class creates the appropriate damage type based on its configuration and applies it to a target. This is a great example of polymorphism because the weapon doesn't need to know the details of how each damage type works—it just creates the damage object and calls Apply().

Example 2: AI Behavior System

// Base AI behavior
public abstract class AIBehavior
{
protected Transform transform;
protected Animator animator;

public AIBehavior(Transform transform, Animator animator)
{
this.transform = transform;
this.animator = animator;
}

public abstract void Update();
public abstract string GetDescription();
}

// Patrol behavior
public class PatrolBehavior : AIBehavior
{
private Transform[] waypoints;
private int currentWaypointIndex;
private float moveSpeed;

public PatrolBehavior(Transform transform, Animator animator, Transform[] waypoints, float moveSpeed)
: base(transform, animator)
{
this.waypoints = waypoints;
this.moveSpeed = moveSpeed;
this.currentWaypointIndex = 0;
}

public override void Update()
{
if (waypoints.Length == 0) return;

// Get current waypoint
Transform currentWaypoint = waypoints[currentWaypointIndex];

// Move towards waypoint
Vector3 direction = (currentWaypoint.position - transform.position).normalized;
transform.position += direction * moveSpeed * Time.deltaTime;

// Rotate towards movement direction
if (direction != Vector3.zero)
{
transform.rotation = Quaternion.LookRotation(direction);
}

// Update animator
animator.SetFloat("Speed", moveSpeed);

// Check if we've reached the waypoint
float distanceToWaypoint = Vector3.Distance(transform.position, currentWaypoint.position);
if (distanceToWaypoint < 0.1f)
{
// Move to next waypoint
currentWaypointIndex = (currentWaypointIndex + 1) % waypoints.Length;
}
}

public override string GetDescription()
{
return "Patrolling";
}
}

// Chase behavior
public class ChaseBehavior : AIBehavior
{
private Transform target;
private float chaseSpeed;
private float maxChaseDistance;

public ChaseBehavior(Transform transform, Animator animator, Transform target, float chaseSpeed, float maxChaseDistance)
: base(transform, animator)
{
this.target = target;
this.chaseSpeed = chaseSpeed;
this.maxChaseDistance = maxChaseDistance;
}

public override void Update()
{
if (target == null) return;

// Calculate distance to target
float distanceToTarget = Vector3.Distance(transform.position, target.position);

// If target is too far, stop chasing
if (distanceToTarget > maxChaseDistance)
{
animator.SetFloat("Speed", 0);
return;
}

// Move towards target
Vector3 direction = (target.position - transform.position).normalized;
transform.position += direction * chaseSpeed * Time.deltaTime;

// Rotate towards target
transform.rotation = Quaternion.LookRotation(direction);

// Update animator
animator.SetFloat("Speed", chaseSpeed);
}

public override string GetDescription()
{
return "Chasing target";
}
}

// Attack behavior
public class AttackBehavior : AIBehavior
{
private Transform target;
private float attackRange;
private float attackCooldown;
private float lastAttackTime;
private int damage;

public AttackBehavior(Transform transform, Animator animator, Transform target, float attackRange, float attackCooldown, int damage)
: base(transform, animator)
{
this.target = target;
this.attackRange = attackRange;
this.attackCooldown = attackCooldown;
this.damage = damage;
this.lastAttackTime = -attackCooldown; // Allow immediate first attack
}

public override void Update()
{
if (target == null) return;

// Calculate distance to target
float distanceToTarget = Vector3.Distance(transform.position, target.position);

// If target is within attack range
if (distanceToTarget <= attackRange)
{
// Face the target
Vector3 direction = (target.position - transform.position).normalized;
transform.rotation = Quaternion.LookRotation(direction);

// Check if we can attack
if (Time.time >= lastAttackTime + attackCooldown)
{
// Perform attack
Attack();

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

private void Attack()
{
// Trigger attack animation
animator.SetTrigger("Attack");

// Apply damage to target
Health targetHealth = target.GetComponent<Health>();
if (targetHealth != null)
{
targetHealth.TakeDamage(damage);
Debug.Log($"{transform.name} attacks {target.name} for {damage} damage!");
}
}

public override string GetDescription()
{
return "Attacking target";
}
}

// Flee behavior
public class FleeBehavior : AIBehavior
{
private Transform target;
private float fleeSpeed;
private float safeDistance;

public FleeBehavior(Transform transform, Animator animator, Transform target, float fleeSpeed, float safeDistance)
: base(transform, animator)
{
this.target = target;
this.fleeSpeed = fleeSpeed;
this.safeDistance = safeDistance;
}

public override void Update()
{
if (target == null) return;

// Calculate distance to target
float distanceToTarget = Vector3.Distance(transform.position, target.position);

// If we're already at a safe distance, stop fleeing
if (distanceToTarget >= safeDistance)
{
animator.SetFloat("Speed", 0);
return;
}

// Move away from target
Vector3 direction = (transform.position - target.position).normalized;
transform.position += direction * fleeSpeed * Time.deltaTime;

// Rotate in the direction we're fleeing
transform.rotation = Quaternion.LookRotation(direction);

// Update animator
animator.SetFloat("Speed", fleeSpeed);
}

public override string GetDescription()
{
return "Fleeing from target";
}
}

// AI controller that uses different behaviors
public class AIController : MonoBehaviour
{
[SerializeField] private Transform[] patrolWaypoints;
[SerializeField] private float patrolSpeed = 2f;
[SerializeField] private float chaseSpeed = 4f;
[SerializeField] private float fleeSpeed = 5f;
[SerializeField] private float maxChaseDistance = 15f;
[SerializeField] private float attackRange = 2f;
[SerializeField] private float attackCooldown = 1.5f;
[SerializeField] private int attackDamage = 10;
[SerializeField] private float safeDistance = 10f;
[SerializeField] private float healthFleeThreshold = 0.3f; // 30% health

private Transform player;
private Animator animator;
private Health health;

private AIBehavior currentBehavior;
private PatrolBehavior patrolBehavior;
private ChaseBehavior chaseBehavior;
private AttackBehavior attackBehavior;
private FleeBehavior fleeBehavior;

private void Awake()
{
animator = GetComponent<Animator>();
health = GetComponent<Health>();

// Find player
player = GameObject.FindGameObjectWithTag("Player")?.transform;

// Create behaviors
patrolBehavior = new PatrolBehavior(transform, animator, patrolWaypoints, patrolSpeed);
chaseBehavior = new ChaseBehavior(transform, animator, player, chaseSpeed, maxChaseDistance);
attackBehavior = new AttackBehavior(transform, animator, player, attackRange, attackCooldown, attackDamage);
fleeBehavior = new FleeBehavior(transform, animator, player, fleeSpeed, safeDistance);

// Start with patrol behavior
currentBehavior = patrolBehavior;
}

private void Update()
{
// Determine the appropriate behavior based on the situation
UpdateBehavior();

// Execute the current behavior
currentBehavior.Update();
}

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

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

// Check if health is low
bool isHealthLow = health != null && health.GetHealthPercentage() <= healthFleeThreshold;

// Determine behavior
if (isHealthLow)
{
// Flee when health is low
SetBehavior(fleeBehavior);
}
else if (distanceToPlayer <= attackRange)
{
// Attack when player is in range
SetBehavior(attackBehavior);
}
else if (distanceToPlayer <= maxChaseDistance)
{
// Chase when player is nearby but not in attack range
SetBehavior(chaseBehavior);
}
else
{
// Patrol when player is far away
SetBehavior(patrolBehavior);
}
}

private void SetBehavior(AIBehavior newBehavior)
{
if (currentBehavior != newBehavior)
{
currentBehavior = newBehavior;
Debug.Log($"{gameObject.name} behavior: {currentBehavior.GetDescription()}");
}
}
}

// Extended Health class with percentage method
public class Health : MonoBehaviour
{
[SerializeField] private float maxHealth = 100f;
private float currentHealth;

private void Awake()
{
currentHealth = maxHealth;
}

public void TakeDamage(float amount)
{
currentHealth -= amount;

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

public float GetHealthPercentage()
{
return currentHealth / maxHealth;
}

private void Die()
{
Debug.Log($"{gameObject.name} has died!");
// Handle death...
}
}

In this example, we have a polymorphic AI behavior system with different types of behaviors (patrol, chase, attack, flee) that all inherit from a base AIBehavior class. Each behavior type has its own implementation of the Update() method, which handles how that specific behavior works.

The AIController class switches between behaviors based on the situation, but it doesn't need to know the details of how each behavior works—it just sets the current behavior and calls Update(). This is polymorphism in action.

Example 3: UI Element System

// Base UI element
public abstract class UIElement : MonoBehaviour
{
[SerializeField] protected string elementId;
[SerializeField] protected bool startVisible = true;

protected RectTransform rectTransform;
protected CanvasGroup canvasGroup;

protected virtual void Awake()
{
rectTransform = GetComponent<RectTransform>();
canvasGroup = GetComponent<CanvasGroup>();

if (canvasGroup == null)
{
canvasGroup = gameObject.AddComponent<CanvasGroup>();
}

SetVisible(startVisible, false);
}

public virtual void Show(bool animate = true)
{
SetVisible(true, animate);
}

public virtual void Hide(bool animate = true)
{
SetVisible(false, animate);
}

protected virtual void SetVisible(bool visible, bool animate)
{
if (!animate)
{
// Instant visibility change
canvasGroup.alpha = visible ? 1f : 0f;
canvasGroup.interactable = visible;
canvasGroup.blocksRaycasts = visible;
}
else
{
// Animated visibility change
StartCoroutine(AnimateVisibility(visible));
}
}

protected virtual IEnumerator AnimateVisibility(bool visible)
{
float targetAlpha = visible ? 1f : 0f;
float startAlpha = canvasGroup.alpha;
float duration = 0.25f;
float elapsed = 0f;

// Set interactable state immediately
canvasGroup.interactable = visible;
canvasGroup.blocksRaycasts = visible;

// Animate alpha
while (elapsed < duration)
{
canvasGroup.alpha = Mathf.Lerp(startAlpha, targetAlpha, elapsed / duration);
elapsed += Time.deltaTime;
yield return null;
}

// Ensure we reach the target alpha
canvasGroup.alpha = targetAlpha;
}

public virtual void SetPosition(Vector2 position, bool animate = true)
{
if (!animate)
{
rectTransform.anchoredPosition = position;
}
else
{
StartCoroutine(AnimatePosition(position));
}
}

protected virtual IEnumerator AnimatePosition(Vector2 targetPosition)
{
Vector2 startPosition = rectTransform.anchoredPosition;
float duration = 0.25f;
float elapsed = 0f;

while (elapsed < duration)
{
rectTransform.anchoredPosition = Vector2.Lerp(startPosition, targetPosition, elapsed / duration);
elapsed += Time.deltaTime;
yield return null;
}

rectTransform.anchoredPosition = targetPosition;
}
}

// Panel UI element
public class UIPanel : UIElement
{
[SerializeField] protected Button closeButton;
[SerializeField] protected bool closeOnEscape = true;

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

if (closeButton != null)
{
closeButton.onClick.AddListener(() => Hide());
}
}

protected virtual void Update()
{
if (canvasGroup.alpha > 0 && closeOnEscape && Input.GetKeyDown(KeyCode.Escape))
{
Hide();
}
}

protected override IEnumerator AnimateVisibility(bool visible)
{
float targetAlpha = visible ? 1f : 0f;
float startAlpha = canvasGroup.alpha;
float duration = 0.3f;
float elapsed = 0f;

// Set interactable state immediately
canvasGroup.interactable = visible;
canvasGroup.blocksRaycasts = visible;

// Start with a slight scale change
Vector3 startScale = visible ? Vector3.one * 0.95f : Vector3.one;
Vector3 targetScale = visible ? Vector3.one : Vector3.one * 0.95f;
rectTransform.localScale = startScale;

// Animate alpha and scale
while (elapsed < duration)
{
float t = elapsed / duration;
canvasGroup.alpha = Mathf.Lerp(startAlpha, targetAlpha, t);
rectTransform.localScale = Vector3.Lerp(startScale, targetScale, t);
elapsed += Time.deltaTime;
yield return null;
}

// Ensure we reach the target values
canvasGroup.alpha = targetAlpha;
rectTransform.localScale = targetScale;
}
}

// Popup UI element
public class UIPopup : UIElement
{
[SerializeField] protected Text titleText;
[SerializeField] protected Text messageText;
[SerializeField] protected Button confirmButton;
[SerializeField] protected Button cancelButton;

protected Action onConfirm;
protected Action onCancel;

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

if (confirmButton != null)
{
confirmButton.onClick.AddListener(OnConfirmClicked);
}

if (cancelButton != null)
{
cancelButton.onClick.AddListener(OnCancelClicked);
}
}

public virtual void Setup(string title, string message, Action onConfirm = null, Action onCancel = null)
{
if (titleText != null) titleText.text = title;
if (messageText != null) messageText.text = message;

this.onConfirm = onConfirm;
this.onCancel = onCancel;
}

protected virtual void OnConfirmClicked()
{
Hide();
onConfirm?.Invoke();
}

protected virtual void OnCancelClicked()
{
Hide();
onCancel?.Invoke();
}

protected override IEnumerator AnimateVisibility(bool visible)
{
float targetAlpha = visible ? 1f : 0f;
float startAlpha = canvasGroup.alpha;
float duration = 0.25f;
float elapsed = 0f;

// Set interactable state immediately
canvasGroup.interactable = visible;
canvasGroup.blocksRaycasts = visible;

// Start with a scale change
Vector3 startScale = visible ? Vector3.zero : Vector3.one;
Vector3 targetScale = visible ? Vector3.one : Vector3.zero;
rectTransform.localScale = startScale;

// Animate alpha and scale with a bounce effect
while (elapsed < duration)
{
float t = elapsed / duration;
canvasGroup.alpha = Mathf.Lerp(startAlpha, targetAlpha, t);

// Add a slight bounce effect when showing
if (visible)
{
float scaleT = Mathf.Sin(t * Mathf.PI * 0.5f);
rectTransform.localScale = Vector3.Lerp(startScale, targetScale, scaleT);
}
else
{
rectTransform.localScale = Vector3.Lerp(startScale, targetScale, t);
}

elapsed += Time.deltaTime;
yield return null;
}

// Ensure we reach the target values
canvasGroup.alpha = targetAlpha;
rectTransform.localScale = targetScale;
}
}

// Tooltip UI element
public class UITooltip : UIElement
{
[SerializeField] protected Text tooltipText;
[SerializeField] protected float padding = 10f;
[SerializeField] protected float followSpeed = 10f;

private bool isFollowingCursor = false;
private Vector2 targetPosition;

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

// Tooltips start hidden
SetVisible(false, false);
}

private void Update()
{
if (isFollowingCursor)
{
// Update target position based on mouse position
targetPosition = Input.mousePosition;

// Adjust position to prevent tooltip from going off-screen
AdjustPositionForScreen();

// Smoothly move towards target position
rectTransform.position = Vector3.Lerp(rectTransform.position, targetPosition, Time.deltaTime * followSpeed);
}
}

public void ShowAtCursor(string text, bool followCursor = true)
{
if (tooltipText != null)
{
tooltipText.text = text;
}

// Resize tooltip based on content
LayoutRebuilder.ForceRebuildLayoutImmediate(rectTransform);

// Set initial position to cursor
targetPosition = Input.mousePosition;
AdjustPositionForScreen();
rectTransform.position = targetPosition;

// Set follow mode
isFollowingCursor = followCursor;

// Show the tooltip
Show();
}

private void AdjustPositionForScreen()
{
// Get tooltip dimensions
Vector2 tooltipSize = rectTransform.sizeDelta;

// Get screen dimensions
Vector2 screenSize = new Vector2(Screen.width, Screen.height);

// Adjust X position
if (targetPosition.x + tooltipSize.x + padding > screenSize.x)
{
targetPosition.x = targetPosition.x - tooltipSize.x - padding;
}
else
{
targetPosition.x += padding;
}

// Adjust Y position
if (targetPosition.y + tooltipSize.y + padding > screenSize.y)
{
targetPosition.y = targetPosition.y - tooltipSize.y - padding;
}
else
{
targetPosition.y += padding;
}
}

public override void Hide(bool animate = true)
{
base.Hide(animate);
isFollowingCursor = false;
}
}

// UI manager that works with different UI elements
public class UIManager : MonoBehaviour
{
[SerializeField] private UIPanel[] panels;
[SerializeField] private UIPopup confirmationPopup;
[SerializeField] private UITooltip tooltip;

private Dictionary<string, UIElement> uiElements = new Dictionary<string, UIElement>();

private void Awake()
{
// Register all UI elements
RegisterUIElements();
}

private void RegisterUIElements()
{
// Register panels
foreach (UIPanel panel in panels)
{
RegisterUIElement(panel);
}

// Register popup
if (confirmationPopup != null)
{
RegisterUIElement(confirmationPopup);
}

// Register tooltip
if (tooltip != null)
{
RegisterUIElement(tooltip);
}
}

private void RegisterUIElement(UIElement element)
{
if (element != null)
{
string id = element.GetType().Name;
SerializedObject serializedObject = new SerializedObject(element);
SerializedProperty idProperty = serializedObject.FindProperty("elementId");

if (idProperty != null && !string.IsNullOrEmpty(idProperty.stringValue))
{
id = idProperty.stringValue;
}

uiElements[id] = element;
Debug.Log($"Registered UI element: {id}");
}
}

public T GetUIElement<T>(string id) where T : UIElement
{
if (uiElements.TryGetValue(id, out UIElement element) && element is T typedElement)
{
return typedElement;
}

Debug.LogWarning($"UI element not found: {id}");
return null;
}

public void ShowPanel(string id)
{
UIPanel panel = GetUIElement<UIPanel>(id);
if (panel != null)
{
panel.Show();
}
}

public void HidePanel(string id)
{
UIPanel panel = GetUIElement<UIPanel>(id);
if (panel != null)
{
panel.Hide();
}
}

public void ShowConfirmation(string title, string message, Action onConfirm = null, Action onCancel = null)
{
if (confirmationPopup != null)
{
confirmationPopup.Setup(title, message, onConfirm, onCancel);
confirmationPopup.Show();
}
}

public void ShowTooltip(string text, bool followCursor = true)
{
if (tooltip != null)
{
tooltip.ShowAtCursor(text, followCursor);
}
}

public void HideTooltip()
{
if (tooltip != null)
{
tooltip.Hide();
}
}
}

// Example usage
public class UITest : MonoBehaviour
{
[SerializeField] private UIManager uiManager;
[SerializeField] private Button inventoryButton;
[SerializeField] private Button characterButton;
[SerializeField] private Button quitButton;

private void Start()
{
// Set up button listeners
inventoryButton.onClick.AddListener(() => uiManager.ShowPanel("InventoryPanel"));
characterButton.onClick.AddListener(() => uiManager.ShowPanel("CharacterPanel"));

quitButton.onClick.AddListener(() => {
uiManager.ShowConfirmation(
"Quit Game",
"Are you sure you want to quit the game? Any unsaved progress will be lost.",
() => Debug.Log("Quitting game..."),
() => Debug.Log("Quit cancelled")
);
});

// Set up tooltip triggers
AddTooltipTrigger(inventoryButton.gameObject, "Open Inventory");
AddTooltipTrigger(characterButton.gameObject, "Open Character Sheet");
AddTooltipTrigger(quitButton.gameObject, "Quit Game");
}

private void AddTooltipTrigger(GameObject obj, string tooltipText)
{
// Add event triggers for tooltip
EventTrigger trigger = obj.GetComponent<EventTrigger>();
if (trigger == null)
{
trigger = obj.AddComponent<EventTrigger>();
}

// Mouse enter
EventTrigger.Entry enterEntry = new EventTrigger.Entry();
enterEntry.eventID = EventTriggerType.PointerEnter;
enterEntry.callback.AddListener((data) => uiManager.ShowTooltip(tooltipText));
trigger.triggers.Add(enterEntry);

// Mouse exit
EventTrigger.Entry exitEntry = new EventTrigger.Entry();
exitEntry.eventID = EventTriggerType.PointerExit;
exitEntry.callback.AddListener((data) => uiManager.HideTooltip());
trigger.triggers.Add(exitEntry);
}
}

In this example, we have a polymorphic UI element system with different types of UI elements (panels, popups, tooltips) that all inherit from a base UIElement class. Each UI element type has its own implementation of methods like Show(), Hide(), and AnimateVisibility(), which handle how that specific UI element behaves.

The UIManager class works with these different UI elements polymorphically, treating them as their base type but getting the specialized behavior of each derived type. This is a great example of polymorphism because the manager doesn't need to know the details of how each UI element works—it just calls methods like Show() and Hide() and lets polymorphism handle the rest.

Best Practices for Polymorphism

  1. Design for Polymorphism: When creating a class hierarchy, think about which methods should be virtual and potentially overridden by derived classes.

  2. Use Abstract Base Classes: Abstract base classes provide a good foundation for polymorphic behavior by defining a common interface that derived classes must implement.

  3. Avoid Type Checking When Possible: Excessive type checking with is, as, or GetType() can defeat the purpose of polymorphism. Try to use virtual methods instead.

  4. Follow the Liskov Substitution Principle: Derived classes should be substitutable for their base classes without altering the correctness of the program.

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

  6. Keep the Base Class Focused: Base classes should provide a clear, cohesive set of functionality that makes sense for all derived classes.

  7. Override ToString() for Debugging: Override the ToString() method in your classes to provide useful debugging information.

  8. Be Careful with Constructors: Ensure base class constructors are called appropriately from derived classes.

  9. Consider Factory Methods: Factory methods can help create the right derived class based on runtime conditions.

  10. Document Virtual Methods: Clearly document the intended behavior of virtual methods so derived classes know how to override them correctly.

Conclusion

Polymorphism is a powerful feature of Object-Oriented Programming that allows you to write more flexible, extensible, and maintainable code. By using polymorphism effectively, you can:

  • Treat objects of different types uniformly through a common interface
  • Extend functionality without modifying existing code
  • Create more modular and reusable components
  • Implement the Open/Closed Principle (open for extension, closed for modification)

In Unity, polymorphism is used extensively, from the component system to event handling to custom game systems. Understanding how to use polymorphism properly will help you create more elegant and adaptable code for your games.

In the next section, we'll explore abstract classes and methods, which provide a powerful way to define common interfaces and partial implementations for derived classes.

Practice Exercise

Exercise: Create a simple ability system for a game with the following requirements:

  1. Create a base Ability class with:

    • Properties for name, description, cooldown, and mana cost
    • A virtual method for activating the ability
    • A method for checking if the ability can be used
  2. Create at least three derived ability types (e.g., FireballAbility, HealAbility, StunAbility) that:

    • Override the activate method with unique behavior
    • Add at least one unique property or method to each
  3. Create a simple Character class that can learn and use abilities.

  4. Demonstrate polymorphism by creating an array or list of the base Ability type and having the character use different abilities without knowing their specific types.

Think about how polymorphism helps you create a flexible ability system that can be easily extended with new ability types in the future.