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 Projectile
s 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.