Fading Coder

One Final Commit for the Last Sprint

Home > Tech > Content

Implementing the Bridge Pattern in Unity Game Development

Tech May 12 2

Introduction to the Bridge Pattern

The Bridge Pattern is one of the 23 classic design patterns that is both incredibly useful yet challenging to fully comprehend. Think of these design patterns as techniques in a martial arts manual. To master each pattern and apply them effectively in your work is like facing an opponent and being able to respond with the appropriate technique from your arsenal. This requires a deep understanding of each pattern to use them fluently and create combinations.

Problem Scenario

In FPS games, we often need to implement a system where both player characters and enemies can use different weapons to eliminate each other. Weapons might include pistols, shotguns, and rocket launchers, distinguished by their "attack power" and "attack range".

Let's consider an initial implementation approach. The following UML diagram illustrates our basic design concept:

Character Base Class

public abstract class GameCharacter
{
    // Equipped weapon
    protected Weapon currentWeapon = null;
    
    // Attack target
    public abstract void Attack(GameCharacter target);
}

Weapon Class

using UnityEngine;

public enum WeaponType
{
    None = 0,
    Pistol,
    Shotgun,
    RocketLauncher,
}

public class Weapon
{
    protected WeaponType weaponType = WeaponType.None;
    protected int attackPower = 0;
    protected int attackRange = 0;
    protected int attackBonus = 0;

    public Weapon(WeaponType type, int power, int range)
    {
        weaponType = type;
        attackPower = power;
        attackRange = range;
    }

    public WeaponType GetWeaponType()
    {
        return weaponType;
    }

    public void Fire(GameCharacter target)
    {
        // Implementation
    }

    public void SetAttackBonus(int bonus)
    {
        attackBonus = bonus;
    }

    public void ShowBulletEffect(Vector3 position, float width, float duration)
    {
        // Implementation
    }

    public void ShowMuzzleEffect()
    {
        // Implementation
    }

    public void PlaySoundEffect(string clipName)
    {
        // Implementation
    }
}

Enemy Implementation

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class Enemy : GameCharacter
{
    public Enemy()
    { }

    public override void Attack(GameCharacter target)
    {
        currentWeapon.ShowMuzzleEffect();
        int attackBonus = 0;
        switch(currentWeapon.GetWeaponType())
        {
            case WeaponType.Pistol:
                // Show weapon effects
                currentWeapon.ShowBulletEffect(target.GetPosition(), 0.3f, 0.2f);
                currentWeapon.PlaySoundEffect("PistolShot");
                attackBonus = CalculateAttackBonus(5, 20);
                break;
            case WeaponType.Shotgun:
                currentWeapon.ShowBulletEffect(target.GetPosition(), 0.4f, 0.2f);
                currentWeapon.PlaySoundEffect("ShotgunShot");
                attackBonus = CalculateAttackBonus(5, 20);
                break;
            case WeaponType.RocketLauncher:
                currentWeapon.ShowBulletEffect(target.GetPosition(), 0.5f, 0.2f);
                currentWeapon.PlaySoundEffect("RocketShot");
                attackBonus = CalculateAttackBonus(5, 20);
                break;
        }
        currentWeapon.SetAttackBonus(attackBonus);
        currentWeapon.Fire(target);
    }
    
    private int CalculateAttackBonus(int chance, int bonusValue)
    {
        int randomValue = UnityEngine.Random.Range(0, 100);
        if (chance > randomValue)
            return bonusValue;
        return 0;
    }
}

Player Character Implementation

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class PlayerCharacter : GameCharacter
{
    public PlayerCharacter()
    {

    }

    public override void Attack(GameCharacter target)
    {
        currentWeapon.ShowMuzzleEffect();
        switch(currentWeapon.GetWeaponType())
        {
            case WeaponType.Pistol:
                currentWeapon.ShowBulletEffect(target.GetPosition(), 0.03f, 0.2f);
                currentWeapon.PlaySoundEffect("PistolShot");
                break;
            case WeaponType.Shotgun:
                currentWeapon.ShowBulletEffect(target.GetPosition(), 0.5f, 0.2f);
                currentWeapon.PlaySoundEffect("ShotgunShot");
                break;
            case WeaponType.RocketLauncher:
                currentWeapon.ShowBulletEffect(target.GetPosition(), 0.8f, 0.2f);
                currentWeapon.PlaySoundEffect("RocketShot");
                break;
        }
        currentWeapon.Fire(target);
    }
}

Initial Analysis

The above implementation has two significant drawbacks:

  1. Whenever a new character type is added, any class inheriting from GameCharacter must reimplement the Attack method with conditional logic for different weapons, leading to duplicated code.
  2. Whenever a new weapon is introduced, all character classes' Attack methods need to be modified, increasing maintenance costs.

To address these issues, we can apply the Bridge Pattern.

The Bridge Pattern

The Bridge Pattern is designed to decouple abstraction from implementation, allowing both to vary independently. In our scenario, characters and weapons represent two separate groups of objects. The initial implementation only considered character inheritance and composition with weapon objects, which resembles the Inversion of Control pattern discussed previously.

Since characters can be designed this way, why not apply the same approach to weapons? The two groups can then be connected through their respective interfaces, much like a matchmaker facilitating communication between two families. This is our interpretation of the Bridge Pattern.

Based on this concept, let's refactor our character and weapon implementations:

  • GameCharacter: The abstract interface for characters holds a reference to an IWeapon object and declares a WeaponAttackTarget() method for subclasses to use. It requires subclasses to implement the Attack() functionality.
  • PlayerCharacter and Enemy: When implementing the Attack() method, these classes simply call the parent's WeaponAttackTarget method to use their current weapon against targets.
  • IWeapon: The weapon interface defining operations and usage methods for weapons in the game.
  • WeaponPistol, WeaponShotgun, WeaponRocketLauncher: The three specific weapon implementations.

Refactored Implementation Using the Bridge Pattern

Weapon Interface

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public abstract class WeaponBase
{
    // Properties
    protected int attackBonus = 0;
    protected int baseAttack = 0;
    protected float range = 0;

    protected GameObject weaponObject = null;
    protected GameCharacter weaponOwner = null;

    // Effects
    protected float effectDuration = 0;
    protected ParticleSystem particleEffect;
    protected AudioSource audioSource;

    // Show bullet effect
    protected void ShowBulletEffect(Vector3 targetPosition, float displayTime)
    {
        effectDuration = displayTime;
    }

    // Show muzzle flash
    protected void ShowMuzzleEffect()
    {
        if(particleEffect != null)
        {
            particleEffect.Stop();
            particleEffect.Play();
        }
    }

    // Play sound effect
    protected void PlaySoundEffect(string clipName)
    {
        if (audioSource == null)
            return;
        
        AssetFactory factory = Factory.GetAssetFactory();
        var clip = factory.LoadAudioClip(clipName);
        if (clip == null)
            return;
            
        audioSource.clip = clip;
        audioSource.Play();
    }

    // Attack target
    public abstract void Fire(GameCharacter target);
}

Pistol Implementation

public class Pistol : WeaponBase
{
    public Pistol()
    {
        // Initialize pistol-specific properties
    }

    public override void Fire(GameCharacter target)
    {
        ShowMuzzleEffect();
        ShowBulletEffect(target.GetPosition(), 0.3f);
        PlaySoundEffect("PistolShot");

        target.ReceiveDamage(weaponOwner);
    }
}

Character Interface

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public abstract class GameCharacter
{
    protected WeaponBase equippedWeapon = null;

    public void EquipWeapon(WeaponBase weapon)
    {
        if (equippedWeapon != null)
            equippedWeapon.Release();
            
        equippedWeapon = weapon;
        // Set weapon owner
        equippedWeapon.SetOwner(this);
    }

    // Get equipped weapon
    public WeaponBase GetWeapon()
    {
        return equippedWeapon;
    }

    protected void SetWeaponAttackBonus(int value)
    {
        equippedWeapon.SetAttackBonus(value);
    }

    protected void UseWeaponOnTarget(GameCharacter target)
    {
        equippedWeapon.Fire(target);
    }

    // Get weapon attack value
    public int GetAttackValue()
    {
        return equippedWeapon.GetBaseAttack();
    }

    /// <summary>
    /// Get attack range
    /// </summary>
    /// <returns></returns>
    public float GetAttackRange()
    {
        return equippedWeapon.GetRange();
    }
    
    /// <summary>
    /// Attack target
    /// </summary>
    /// <param name="target"></param>
    public abstract void Attack(GameCharacter target);
    
    /// <summary>
    /// Receive damage from another character
    /// </summary>
    /// <param name="attacker"></param>
    public abstract void ReceiveDamage(GameCharacter attacker);
}

Character Implementations

// Player character
public class Player : GameCharacter
{
    public override void Attack(GameCharacter target)
    {
        UseWeaponOnTarget(target);
    }

    public override void ReceiveDamage(GameCharacter attacker)
    {
        // Handle damage received
        ...
    }
}

// Enemy character
public class Enemy : GameCharacter
{
    public override void Attack(GameCharacter target)
    {
        SetWeaponAttackBonus(equippedWeapon.GetAttackBonus());
        UseWeaponOnTarget(target);
    }

    public override void ReceiveDamage(GameCharacter attacker)
    {
        // Handle damage received
        ...
    }
}

Analysis of the Bridge Pattern Implementation

In this refactored design, GameCharacter serves as the "abstraction" group, defining the "attack target" functionality, while the actual implementation is handled by the "WeaponBase weapon" group. Changes to the Weapon group don't affect the Character group or its inheritance hierarchy. This is especially beneficial when adding new weapon types, as it doesn't impact existing character classes.

For GameCharacter, it only interacts with the IWeapon interface, minimizing the coupling between the two groups. This separasion of concerns makes the system more maintainable and extensible.

Further Application: Rendering Engines

Consider another application of this pattern: using different rendering engines like OpenGL or DirectX to render various shapes.

In this scenario, rendering engines and shapes represent two distinct groups. Each group should have its own abstraction:

  • The RenderEngine abstraction should include an abstract Draw(string shape) method, which would be overridden by specific rendering engines (OpenGL, DirectX) to implement their unique rendering logic.
  • The Shape abstraction should include a protected RenderEngine reference, a method to set this reference (SetRenderEngine), and an abstract Draw() method. The Draw() method would be overridden by specific shapes to call the renderEngine's Draw method to render themselves.

This approach allows you to add new rendering engines or new shape types independently without modifying existing code, demonstrating the power and flexibility of the Bridge Pattern.

Tags: Unity

Related Articles

Understanding Strong and Weak References in Java

Strong References Strong reference are the most prevalent type of object referencing in Java. When an object has a strong reference pointing to it, the garbage collector will not reclaim its memory. F...

Comprehensive Guide to SSTI Explained with Payload Bypass Techniques

Introduction Server-Side Template Injection (SSTI) is a vulnerability in web applications where user input is improper handled within the template engine and executed on the server. This exploit can r...

SBUS Signal Analysis and Communication Implementation Using STM32 with Fus Remote Controller

Overview In a recent project, I utilized the SBUS protocol with the Fus remote controller to control a vehicle's basic operations, including movement, lights, and mode switching. This article is aimed...

Leave a Comment

Anonymous

◎Feel free to join the discussion and share your thoughts.