Implement weapons using ScriptableObjects that have different fire methods and different projectiles

tl;dr: I want some of my weapons to implement different Fire() methods. What is the best way to go about that? Do they each need their own specialized ScriptableObjects that implement a Weapons interface?

I’m trying to figure out the best way in Unity to create as much reusability and scalability as possible with my weapons implementation.

I want to use ScriptableObjects or even plain objects as much as possible to avoid having needless game objects and prefabs accumulating. Unless someone recommends otherwise.

I want the weapons and projectiles to have there own stats like speed, fire rate, damage dealt… etc. That seems relatively straightforward. I could use ScriptableObjects, plug in those values and add them to the create asset menu, then add the prefab onto them when I create new ones. But where I’m getting hung up is, I want some of my weapons to implement different Fire() methods. What is the best way to go about that? Do they each need their own specialized ScriptableObjects that implement a Weapons interface?

This is my first post here. If this isn’t the appropriate place to ask this sort of question, can you please point me in the right direction?

Thanks for the guidance.

This is a noble goal. First I recommend getting it going naively with 2 or 3 hard-coded and as-different-as-possible weapons and then stepping back and studying it for ways to refactor.

Otherwise you simply do not have enough hands-on experience with the problem in your specific game context to make judgements in advance. Nobody does. That’s what engineering is about.

Fortunately we’re engineering in software, which means it is “soft,” which means it can change easily. Implement, analyze, refactor.

1 Like

You’re in luck; I’m implementing pretty much the same thing in my game.
The way I’ve gone about it is by splitting things into 4 key components:

  • A plain-old-C#-object containing the stats of the weapon.

  • A ScriptableObject containing a reference to those weapon stats, which can be reused anywhere.

  • A MonoBehaviour representing the type of munition that the weapon fires, which has a reference to the weapon that fired it, allowing it to access its stats.

  • A MonoBehaviour of the actual weapon itself, which contains a reference to both that ScriptableObject and a copy of its stats (I’ll explain why there’s a copy instead of a direct reference later).

And for different ways of firing, there are optional components that can be enabled/disabled to modify the behaviour.

I’ll give you an example on how I’ve added projectile weapons, for instance. This won’t be exactly how I did it, since that would involve more unnecessary/irrelevant code for this example, and this example is already long enough as it is.

Structure

Structure

The first component is a ProjectileWeaponStats C# object similar to this:

[System.Serializable]
public class ProjectileWeaponStats {
   public int damage; //How much damage the weapon deals.
   public float fireRate; //The time between each shot.
   public float shotSpread; //The accuracy of the weapon (between a 0 and 360 degree fire-arc).
   public float shotSpeed; //How fast the weapon's projectiles travel in the scene.
   public float shotLifetime; //How long the weapon's projectiles can stay alive in the scene.
   public int shotsPerFire; //How many projectiles the weapon fires at once. I.E: a shotgun-type of weapon would fire multiple projectiles in one shot.

   //Just assign some default values for this constructor.
   public ProjectileWeaponStats() {
      this.damage = 1;
      this.fireRate = 1f;
      this.shotSpread= 0f;
      this.shotSpeed = 1f;
      this.shotLifetime = 1f;
      this.shotsPerFire = 1;
   }
 
   //This constructor comes in handy for later.
   public ProjectileWeaponStats(ProjectileWeaponStats copy) {
      this.damage = copy.damage;
      this.fireRate = copy.fireRate;
      this.shotSpread = copy.shotSpread;
      this.shotSpeed = copy.shotSpeed;
      this.shotLifetime = copy.shotLifetime;
      this.shotsPerFire = copy.shotsPerFire;
   }
}

Next is the ProjectileWeaponData ScriptableObject that holds a reference to those stats. You could include any other information in the ScriptableObject as well, say like the name and price of the weapon if players can purchase it in a shop:

public class ProjectileWeaponData : ScriptableObject {
   [SerializeField] private ProjectileWeaponStats stats;

   public ProjectileWeaponStats Stats => stats;
}

Then the Projectile MonoBehaviour, which is what a ProjectileWeapon will fire (we’ll see the ProjectileWeapon next):

public class Projectile : MonoBehaviour {
   public ProjectileWeapon linkedWeapon = null; //The reference to the ProjectileWeapon that fired this Projectile.

   private void Start() {
      if(linkedWeapon == null) {
         throw new UnassignedReferenceExeption("A Projectile instance does not have a linked ProjectileWeapon refernece.");
      }
   }
}

Now in my case, I wanted to make Projectiles and other munition types be unable to exist without having a reference to their linkedWeapon property, since this property would contain the stats that it would use to interact in the scene, which is why I added the exception in its Start method.
You can omit that part if you want to allow munition instances to exist regardless.

And finally, there’s the ProjectileWeapon MonoBehaviour itself, which has a reference to ProjectileWeaponData, a copy of ProjectileWeaponStats, and a reference to the Projectile prefab that it fires:

public class ProjectileWeapon : MonoBehaviour {
   [SerializeField] private Projectile projectilePrefab = null;
   [SerializeField] private ProjectileWeaponData baseData = null;
   public ProjectileWeaponStats stats = new ProjectileWeaponStats();

   private void Awake() {
      //A ProjectileWeapon must have a Projectile prefab assigned to it, otherwise it can't fire anything.
      if(projectilePrefab == null) {
         throw new UnassignedReferenceException("A Projectile prefab has not been assigned to a ProjectileWeapon.");
      }

      //A ProjectileWeapon must have a ProjectileWeaponData reference assigned, where it will copy its base stats.
      if(baseData == null) {
         throw new UnassignedReferenceException("A ProjectileWeaponData reference has not been assigned to a ProjectileWeapon.");
      }

      //Copy the stats from the base ProjectileWeaponData onto this ProjectileWeapon's stats.
      stats = new ProjectileWeaponStats(baseData.Stats);
   }
}

Now the reason why the ProjectileWeapon has a copy of ProjectileWeaponStats is because maybe you want to modify the stats of your weapons during gameplay, such as when the player picks up a power-up that increases their fire-rate temporarily or something.
If you were to modify the stats in the ProjectileWeaponData ScriptableObject directly, those changes would persist, and whatever the original stats of the weapon was would be gone.

Creating a copy of the base stats allows them to be modified during gameplay however you want, without permanently changing the original values.

Usage

Usage

Here’s the basic gist how the ProjectileWeapon fires its Projectiles.

public class ProjectileWeapon : MonoBehaviour {
   [SerializeField] private Projectile projectilePrefab = null;
   [SerializeField] private ProjectileWeaponData baseData = null;
   public ProjectileWeaponStats stats = new ProjectileWeaponStats();

   private float nextFireTime = 0f;

   public void Fire() {
      if(Time.time < nextFireTime) return;

      for(int i = 0; i < stats.shotsPerFire; i++) {
         GameObject instance = Instantiate(projectilePrefab.gameObject, transform.position, transform.rotation);

         Projectile projectile = instance.GetComponent<Projectile>();
         projectile.linkedWeapon = this; //Don't forget this part, otherwise the Projectile will have no reference to this weapon's stats.
         projectile.Fire();
      }

      nextFireTime = Time.time + stats.fireRate;
   }
}

And when the Projectile is fired:

public class Projectile : MonoBehaviour {
   public ProjectileWeapon linkedWeapon = null;

   private float remainingLifetime;

   public void Fire() {
      remainingLifetime = linkedWeapon.stats.shotLifetime;

      //Offset the starting rotation of the Projectile based on the shotSpread value of the linkedWeapon's ProjectileWeaponStats.
      float shotSpread = linkedWeapon.stats.shotSpread / 2;

      Vector3 rotationOffset = new Vector3 {
         x = Random.Range(-shotSpread, shotSpread),
         y = Random.Range(-shotSpread, shotSpread),
         z = Random.Range(-shotSpread, shotSpread)
      };

      transform.Rotate(rotationOffset);
   }

   private void Update() {
      if(remainingLifetime > 0) {
         remainingLifetime -= Time.deltaTime;

         //Move the Projectile in the scene over time with a speed based on the "shotSpeed" value of the linkedWeapon's stats.
         transform.Translate(Vector3.forward * Time.deltaTime * linkedWeapon.stats.shotSpeed);
      }
      else {
         Destroy(gameObject);
      }
   }
}

In reality, I’m using an object pool for Projectile instances, so that there aren’t frequent Instantiate/Destroy and GetComponent calls, but again, that’s unrelated to this example.

Different Ways of Firing

Different Ways of Firing

There are actually two more fields in the ProjectileWeaponStats class that I omitted until now: a struct defining if and how the weapon charges-up before firing, and another struct defining if and how the weapon burst-fires:

[System.Serializable]
public class ProjectileWeaponStats {
   //^ previous fields above...

   public WeaponCharge charge;
   public WeaponBurst burst;

   //Just assign some default values for this constructor.
   public ProjectileWeaponStats() {
      //^ previous fields above...

      this.charge = default;
      this.burst = default;
   }
 
   //This constructor comes in handy for later.
   public ProjectileWeaponStats(ProjectileWeaponStats copy) {
      //^ previous fields above...

      this.charge = copy.charge;
      this.burst = copy.burst;
   }
}

And those structs look like this:

[System.Serializable]
public struct WeaponCharge {
   [SerializeField] private bool enabled; //Whether or not charging is enabled for the weapon.
   [SerializeField] private float chargeTime; //How long (in seconds) it takes for the weapon to fully charge-up.
   [SerializeField] private float damageMultiplier; //How much more damage a weapon deals after being charge-fired.

   public WeaponCharge(bool enabled, float chargeTime, float damageMultiplier) {
      this.enabled = enabled;
      this.chargeTime = chargeTime;
      this.damageMultiplier = damageMultiplier;
   }

   public bool Enabled => enabled;
   public float ChargeTime => chargeTime;
   public float DamageMultiplier => damageMultiplier;
}
[System.Serializable]
public struct WeaponBurst {
   [SerializeField] private bool enabled; //Whether or not burst-firing is enabled for the weapon.
   [SerializeField] private int shotsPerBurst; //The total amount shots fired in one burst.
   [SerializeField] private float timeBetweenShots; //The delay (in seconds) between each shot fired during a burst.

   public WeaponBurst (bool enabled, int shotsPerBurst, float timeBetweenShots) {
      this.enabled = enabled;
      this.shotsPerBurst = shotsPerBurst;
      this.timeBetweenShots = timeBetweenShots;
   }

   public bool Enabled => enabled;
   public float ShotsPerBurst => shotsPerBurst;
   public float TimeBetweenShots => timeBetweenShots;
}

And those are implemented in ProjectileWeapon like so:

public class ProjectileWeapon : MonoBehaviour {
   [SerializeField] private Projectile projectilePrefab = null;
   [SerializeField] private ProjectileWeaponData baseData = null;
   public ProjectileWeaponStats stats = new ProjectileWeaponStats();

   private float nextFireTime = 0f;
   private float currentChargeTime = 0f;

   //Assumed this method is called every frame, I.E: When the user is holding down an input.
   public void Fire() {
      if(Time.time < nextFireTime) return;

      if(stats.charge.Enabled) {
         ChargeUp();
      }
      else {
         DoFireSequence();
      }
   }

   //While Fire() is being called, wait until the currentChargeTime has reached the "ChargeTime" value in stats.charge before firing.
   private void ChargeUp() {
      float maxChargeTime = stats.charge.ChargeTime;
      currentChargeTime += Time.deltaTime;

      if(currentChargeTime > maxChargeTime) {
         DoFireSequence();
         currentChargeTime = 0f;
      }
   }

   //Before firing right away, check if the weapon can burst-fire first.
   //If it can, run the BurstFireProjectiles coroutine, otherwise simply FireProjectiles() as usual.
   private void DoFireSequence() {
      nextFireTime = Time.time + stats.fireRate;

      if(stats.burst.Enabled) {
         StartCoroutine(BurstFireProjectiles());
         nextFireTime += stats.burst.TimeBetweenShots * stats.burst.ShotsPerBurst;
      }
      else {
         FireProjectiles();
      }
   }

   private void FireProjectiles() {
      for(int i = 0; i < stats.shotsPerFire; i++) {
         GameObject instance = Instantiate(projectilePrefab.gameObject, transform.position, transform.rotation);

         Projectile projectile = instance.GetComponent<Projectile>();
         projectile.linkedWeapon = this;
         projectile.Fire();
      }
   }

   //From the "burst" value in the weapon's stats, loop through the "ShotsPerBurst" and FireProjectiles() with
   //the delay between each fire specified by "TimeBetweenShots".
   private IEnumerator BurstFireProjectiles() {
      WaitForSeconds delay = new WaitForSeconds(stats.burst.TimeBetweenShots);

      for(int i = 0; i < stats.burst.ShotsPerBurst; i++) {
         FireProjectiles();
         yield return delay;
      }
   }
}

After setting this all up, I’m left with some very highly customizable weapon scripts that can be reused across multiple weapon prefabs with completely different behaviours.

2 Likes

Seems really straightforward to me.

public class Weapon : ScriptableObject
{
    public virtual void Fire()
    {
        // General code here, if needed
    }
   
    // Reload method etc.
}

public class Pistol : Weapon
{
    public override void Fire()
    {
        base.Fire():
       
        // Pistol fire logic here
    }
}

If every single weapon will have a unique fire mode, this will do just fine. If you want to reuse fire modes, then you’ll have to expand this a bit more, but in a similar fashion. Most likely a parent “FireMode” class which gets referenced in Pistol.Fire().

1 Like

What I do is use scriptable objects for variables (can also use inheritance here if these should differ between different types of weapon). Then I’ll have a weapon data script on each of my actual weapon objects that will hold a reference to the relevant scriptable object. This handles my data.

For individual weapon behaviour I work with small components and interfaces. So I might have 5 different ways to fire, which means 5 different scripts each with their own IShootable interface and Shoot() method. I find this is normally enough for the weapons themselves. My projectiles are usually designed in a similar fashion but I tend to have a lot more components for things such as homing, scatter shots, area of effect etc etc.

I find this work flow easy to adjust and very expandable. If I want a new behaviour then I just write another small script and drag it onto any prefab that I want to have that behaviour.