Composition in ScriptableObjects

Given a ScriptableObject class ‘Attack’, how do i properly apply composition?

(All code below)

I have a class ‘ProjectileAttack’ that fires a projectile and runs on a cooldown system. Also below is a MultiProjectileAttack class which does the same except fires multiple projectiles per shot. If i eventually have hundreds of these attacks, how do i properly apply composition?

For example, i want to add a ‘Charge’ system, where a weapon holds a number of charges that can be expended, and regenerated. I now have to write the charge ScriptableObject script for each attack type. So for single projectiles i have to write a charge script that inherits from ‘ProjectileAttack’ and again for ‘MultiProjectileAttack’. The charge script would have no difference for either ‘ProjectileAttack’ or MultiProjectileAttack’ but i would nonetheless have to create two scripts for each attack type. If i had more attack types i would have to do the same. How do i apply the principles of composition here, ie. one script for all the attack types.

Now granted i could have all the attacks work on the charge system and simply set the single charge weapons max charges to 1 (it would look exactly the same) but that’s not my point. I want to be able to cleaner apply composition here.

This isn’t just for the charge script either, i have plenty of other scripts i want to add.

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

public abstract class Attack : ScriptableObject
{
    public abstract bool CanAttack(GameObject attacker);
    public abstract void OnAttack(GameObject attacker);
    public float AS = 1f;
    public bool offCD;
}
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

[CreateAssetMenu(fileName = "New Projectile Attack", menuName = "Attack/Projectile")]
public class ProjectileAttack : Attack
{
    public GameObject projectilePrefab; 
    public float force;
    public override bool CanAttack(GameObject attacker)
    {
        return offCD;
    }

    public override void OnAttack(GameObject attacker){
        if(offCD){
            Cooldown(attacker);
            Attack(attacker);
        }
    }

    public void Cooldown(GameObject attacker){
        offCD = false;
        attacker.GetComponent<Cooldowns>().WaitAndDo(AS, () => offCD = true);
    }
    public void Attack(GameObject attacker)
    {
            Vector3 sp = Camera.main.WorldToScreenPoint(attacker.transform.position);
            Vector3 dir = (Input.mousePosition - sp).normalized;
            GameObject objectInstance = Instantiate(projectilePrefab,
            attacker.transform.position, Quaternion.Euler(new Vector3(0, 0, 0)));
            // Points missile at mouse pos
            float rot_z = Mathf.Atan2(dir.y, dir.x) * Mathf.Rad2Deg;
            objectInstance.transform.rotation = Quaternion.Euler(0f, 0f, rot_z - 90);

            objectInstance.GetComponent<Rigidbody2D>().AddForce(dir * force);
    }
}
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

[CreateAssetMenu(fileName = "New Triple Projectile Attack", menuName = "Attack/MultiProjectile")]
public class MultiProjectileAttack : ProjectileAttack
{
    public int shotsPerAttack;
    public float waitTimeBetweenShots;
  
    protected IEnumerator firingThreeShots;
    public override void OnAttack(GameObject attacker)
    {
        if(offCD){
            Cooldown(attacker);
            firingThreeShots = FireShots(attacker);
            CoroutineBypass.instance.StartCoroutine(firingThreeShots);
        }
    }
  
    protected IEnumerator FireShots(GameObject attacker)
    {
       
        for(int i = 0; i < shotsPerAttack; i++)
        {
            Attack(attacker);
            yield return new WaitForSeconds(waitTimeBetweenShots);
        }
    }
}

Having some trouble understanding your question. Trying to understand if you mean charge as in charging-up-an-attack as with a bow and arrow, or charge as in charges-remaining as with ammo. Assuming you meant ammo.

Instead of re-implementing the OnAttack method in both ProjectileAttack and MultiProjectileAttack you may instead just implement the actual instantiation in the attack method. May suggest renaming OnAttack to TryAttack, then having it call a second OnAttack that is then overrideable.

This is a perfect use case of the inheriting methods. Implement the charge/ammo system in the base Attack class so your other classes may take use of it. Then maybe change the implementation to support continuous attacks with an OnAttackStart and OnAttackEnd instead for those lazer attacks.

Example:

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

public abstract class Attack : ScriptableObject
{
    public float AS = 1f;
    public bool offCD;
    public int ammoLeft = 10;
    public int ammoLimit = 10;
    public float reloadTimeSeconds = 5f;

    public abstract void OnAttack(GameObject attacker);

    public virtual bool CanAttack(GameObject attacker)
    {
        return offCD;
    }

    public void TryAttack(GameObject attacker)
    {
        if (CanAttack(attacker))
        {
            ammoLeft--;
          
            OnAttack(attacker);

            if (ammoLeft > 0)
            {
                Cooldown();
            }
            else
            {
                Reload();
            }
        }
    }
  
    public void Cooldown()
    {
        offCD = false;
        attacker.GetComponent<Cooldowns>().WaitAndDo(AS, () => offCD = true);
    }

    public void Reload()
    {
        offCD = false;
        attacker.GetComponent<Cooldowns>().WaitAndDo(reloadTimeSeconds, () => {
            offCD = true;
            ammoLeft = ammoLimit;
        });
    }
}
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

[CreateAssetMenu(fileName = "New Projectile Attack", menuName = "Attack/Projectile")]
public class ProjectileAttack : Attack
{
    public GameObject projectilePrefab;
    public float force;
  
    public override void OnAttack(GameObject attacker){
        FireSingleShot(attacker);
    }
    public void FireSingleShot(GameObject attacker)
    {
        Vector3 sp = Camera.main.WorldToScreenPoint(attacker.transform.position);
        Vector3 dir = (Input.mousePosition - sp).normalized;
        GameObject objectInstance = Instantiate(projectilePrefab,
        attacker.transform.position, Quaternion.Euler(new Vector3(0, 0, 0)));
        // Points missile at mouse pos
        float rot_z = Mathf.Atan2(dir.y, dir.x) * Mathf.Rad2Deg;
        objectInstance.transform.rotation = Quaternion.Euler(0f, 0f, rot_z - 90);

        objectInstance.GetComponent<Rigidbody2D>().AddForce(dir * force);
    }
}
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

[CreateAssetMenu(fileName = "New Triple Projectile Attack", menuName = "Attack/MultiProjectile")]
public class MultiProjectileAttack : ProjectileAttack
{
    public int shotsPerAttack;
    public float waitTimeBetweenShots;
    public override void OnAttack(GameObject attacker)
    {
        StartCoroutine(FireShots(attacker));
    }
    protected IEnumerator FireShots(GameObject attacker)
    {
        for(int i = 0; i < shotsPerAttack; i++)
        {
            FireSingleShot(attacker);
            yield return new WaitForSeconds(waitTimeBetweenShots);
        }
    }
}

Sorry if i wasn’t clear, i meant charges as in you have three charges of a weapon, you use one and it starts to regenerate. So you can fire three in quick succession and wait till they regenerate, or fire one at a time etc.

I don’t want to use your implementation as i don’t want all my Attacks to inherit the variables you’ve added. Attacks can be a large range of things, including Melee attacks, so i certainty don’t want all the differing attacks to inherit variables and functions that they will never use and clog up the inspector. I now have attacks (melee, cooldown etc) that have variables inherited that are irrelevant to that attack, which is what i’m trying to avoid. This is why i’m specifically avoiding writing the solution in the base class.

Hence why i mentioned this isn’t just for the charge system. If i had even multiple systems like this into the base class i now suddenly have a stream of variables and functions that only a specific set of attacks would use, and many of my attacks would now have variables in the inspector that when changed have no effect on the attack.

Right! I believe I understand the dilemma. Like, let’s say you have a projectile based attack and a melee attack that you want to use the charge technique on, but not all projectile based attacks nor melee, nor the base attack class either, should have to implement this, while also following the DRY (dont-repeat-yourself) principle. It would also be beneficial to not have to add separate components for the different logics, just that they all include the logic embedded within them.

One way you could do this to move as much of the logic as possible to a singular place is by using interfaces to specify that the attack class needs to implement a ChargesLeft, then using an utility function that does the recharging logic that you call from within the attack class.

Something like:

public interface IChargeableAttack {
    int ChargesLeft { get; set; }
    int ChargesLimit { get; set; }
    int ChargesReloadTime { get; set; }
}
using UnityEngine;

public static class ChargeableAttackUtility
{
    public static bool TryUseUpCharge<T>(T chargeable)
        where T : Attack, IChargeableAttack
    {
        if (chargeable.ChargesLeft == 0)
        {
            return false;
        }

        // Only start the timer when going from max charge to recharging
        if (chargeable.ChargesLeft == chargeable.ChargesLimit) {
            CoroutineBypass.instance.StartCoroutine(RechargeCoroutine(chargeable)));
        }

        chargeable.ChargesLeft--;

        return true;
    }

    private static IEnumerable RechargeCoroutine(IChargeableAttack chargeable)
    {
        while (chargeable.ChargesLeft < chargeable.ChargesLimit)
        {
            yield return new WaitForSeconds(chargeable.ChargesReloadTime);
            chargeable.ChargesLeft++;
        }

        // fully charged
    }
}
using UnityEngine;

public class ChargeableMeleeAttack : Attack, IChargeableAttack
{
    public int ChargesLeft { get; set; }
    public int ChargesLimit { get; set; }
    public int ChargesReloadTime { get; set; }

    public override bool CanAttack(GameObject attacker)
    {
        return ChargesLeft > 0;
    }

    public override void OnAttack(GameObject attacker)
    {
        if (ChargeableAttackUtility.TryUseUpCharge(this))
        {
            // do the attack
        }
    }
}

Having to add ChargesLeft, ChargesLimit, and ChargesReloadTime to all classes that are chargeable cannot be avoided when using an interface based solution like this.

I haven’t actually tried the code. But the general idea is there

1 Like

Hmm, that certainly helps thanks, i’ll look into that.

I think it still runs into the repeat yourself problem though, right? Say i have 50 different types of attacks, (melee, projectile etc) and i want 20 of them to incorporate charges, i have to create 20 separate scripts that do the same thing for the charges eg. ChargeableMeleeAttack, ChargeableProjectileAttack, Charable… etc 20 times. ChargeableMeleeAttack would do the exact same thing as ChargeableProjectileAttack with the only difference being where it inherits from (the code would be exactly the same except for the class name and where it inherits from). And then say i want to add more systems to the attacks. Say i have a system that can incorporate normal attacks AND charged type attacks. Now i have to create scripts for every variation of Chargeable and normal attacks (eg. NewSystemChareableMeleeAttack, NewSystemChareableProjectileAttack, NewSystemProjectileAttack) and the amount of scripts i have to create multiplies by the number of systems i have and the amount of attacks i have. Whereas in a composition type system i would just slap whatever i needed onto a prefab.

Am i seeing this wrong? Surely there is a better solution than writing a script for every possible variation?

Yea ok so doing it via separate components is more suitable then.

Here’s some boiler plate to get give you an idea of how I would do it:

  • One interface to define events for an attack that each modifier needs to respond to.
  • One coordinator monobehaviour on your player GameObject with a list of attack modifiers, that ensures all modifiers are checked and events are called during a full attack process.
  • Multiple attack modifier monobehaviours on the same GameObject that registers themselves as attacks to correctly respond to the different phases in the attack process.

Sample code:

IAttack.cs

// you could also make an abstract AttackBaseMonoBehaviour class
// that derives from MonoBehaviour and IAttack to contain some default implementations
// of the methods, for ease of use. Similar to how I did it in AttackProjectileShooter.cs

public interface IAttack
{
    void OnAttackStart();
    void OnAttackCancel();
    void OnAttackEnd();
    bool CanEndAttack();
    bool CanStartAttack();
}

AttackCoordinator.cs

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

public class AttackCoordinator : MonoBehaviour
{
    // Having SerializeField + HideInInspector makes a field resilient to
    // hot-reloading and still remains invisible
    [SerializeField, HideInInspector]
    private List<IAttack> registeredAttacks;

    [SerializeField, HideInInspector]
    private bool isAttacking;

    public bool IsAttacking => isAttacking;

    public void RegisterAttack(IAttack attack)
    {
        if (!registeredAttacks.Contains(attack))
        {
            registeredAttacks.Add(attack);
        }
    }

    public void StartAttack()
    {
        if (CanAllStartAttack())
        {
            foreach (var attack in registeredAttacks)
            {
                attack.OnAttackStart();
            }

            isAttacking = true;
        }
    }

    public void CancelAttack()
    {
        if (isAttacking)
        {
            foreach (var attack in registeredAttacks)
            {
                attack.OnAttackCancel();
            }

            isAttacking = false;
        }
    }

    public void EndAttack()
    {
        if (CanAllStartAttack())
        {
            foreach (var attack in registeredAttacks)
            {
                attack.OnAttackEnd();
            }
        }

        isAttacking = false;
    }

    public void CanAllStartAttack()
    {
        if (!isAttacking)
        {
            return false;
        }

        return registeredAttacks.All(a => a.CanEndAttack());
    }

    public void CanAllStartAttack()
    {
        if (isAttacking)
        {
            return false;
        }

        return registeredAttacks.All(a => a.CanStartAttack());
    }

    void Update()
    {
        // Sample update function to run attack coordinator using the mouse
        if (Input.GetButtonDown("Cancel"))
        {
            CancelAttack();
        }

        if (Input.GetButtonDown("Fire1"))
        {
            StartAttack();
        }

        if (Input.GetButtonUp("Fire1"))
        {
            EndAttack();
        }
    }
}

Then some example implementations:

AttackAmmo.cs

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

public class AttackAmmo : MonoBehaviour, IAttack
{
    public int chargesLeft = 10;
    public int chargesLimit = 10;
    public float reloadTime = 5;

    [SerializeField, HideInInspector]
    private float reloadTimeLeft = 0;

    void OnAwake()
    {
        GetComponent<AttackCoordinator>().RegisterAttack(this);
    }

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

            if (reloadTimeLeft <= 0)
            {
                ReloadAmmo();
            }
        }
    }

    public void ReloadAmmo()
    {
        if (chargesLeft < chargesLimit)
        {
            chargesLeft++;
            reloadTimeLeft += reloadTime;
        }
    }

    void IAttack.OnAttackStart() { }

    void IAttack.OnAttackCancel() { }

    void IAttack.OnAttackEnd() { }

    bool IAttack.CanEndAttack()
    {
        return chargesLeft > 0;
    }

    bool IAttack.CanStartAttack()
    {
        return chargesLeft > 0;
    }
}

AttackProjectileShooter.cs

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

public class AttackProjectileShooter : MonoBehaviour, IAttack
{
    public GameObject projectilePrefab;

    void OnAwake()
    {
        GetComponent<AttackCoordinator>().RegisterAttack(this);
    }

    public void FireProjectile()
    {
        // Super simplified
        Instantiate(projectilePrefab, transform.position, transform.rotation);
    }

    void IAttack.OnAttackStart() { }

    void IAttack.OnAttackCancel() { }

    // virtual so that AttackProjectileArrayShooter may override it
    public virtual void OnAttackEnd()
    {
        FireProjectile();
    }

    bool IAttack.CanEndAttack()
    {
        return true;
    }

    bool IAttack.CanStartAttack()
    {
        return true;
    }
}

AttackProjectileArrayShooter.cs

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

public class AttackProjectileArrayShooter : AttackProjectileShooter
{
    public int shotsPerAttack = 3;
    public float waitTimeBetweenShots = 0.2f;

    public IEnumerator FireProjectileArrayCoroutine()
    {
        for (int i = 0; i < shotsPerAttack; i++)
        {
            FireProjectile();
            yield return new WaitForSeconds(waitTimeBetweenShots);
        }
    }

    public override void OnAttackEnd()
    {
        StartCoroutine(FireProjectileArrayCoroutine());
    }
}

Now your game seemed to be locked on who you’re attacking so passing that GameObject on might be necessary for your context, but that would be easily modifiable, just add that as parameter to the interface and the implementations and then add code to the attack coordinator to properly distribute the value.

BIG NOTE: I’ve not actually tested the above code, I just threw it together as a proof-o-concept, and not necessarily a working one. Just an architecture design proposal. Hope I’ve grasped your question correctly.

1 Like