2
\$\begingroup\$

In my game, the player has pickups that they can activate that each do different things. Some examples include:

  • Restore your health
  • Gives you a one time speed boost
  • Give you a bubble that bounces you off the ground
  • Move all the coins within range closer to you
  • Boost you to a certain velocity for x seconds then returns you to your previous speed
  • Turn off collision with enemies

These are all in their own ScriptableObjects that derive from a base Pickup ScriptableObject that has Activate, Tick, and Deactivate functions that are overridden per Pickup. These work fine (but not without their troubles), but the problem I am coming to have is implementing an upgrade system with them.

The player has and saves a list of Upgrade ScriptableObjects that detail what Pickup it is targeting, and what aspects about the Pickup it is modifying, along with the value that the upgrade gives. Things like "Magnet, Duration, 3 seconds" or "Chainsaw, Damage, 15".

The solution I came up with was to have Pickup variables that are runtime only, like duration, range, speed, horizontalRange, verticalRange, etc. and another Pickup function to be overridden, "Setup", which goes through all the Upgrades that the player has, finds which ones that target that Pickup, and then manually updates that runtime variable based on the different levels that the upgrade is at before any more processing is done. A special thing to note is that an upgrade can affect multiple aspects per level, such as level 1 upgrading duration, level 2 upgrading range, and level 3 upgrading both.

This comes with the problem that some Pickups use vastly different variables, such as a health restore not caring about a range, duration, or anything else besides "how much health do I restore". "Magnet" and "Jump Boots" both care about a duration, but not a range, while I might in the future make an upgrade that cares about damage, range, and speed... So I would be retyping the same code for 20+ Pickups with slight modifications, like one going through all range upgrades, one going through all duration upgrade, etc.

Relevant Scripts:

Pickup.cs

public abstract class Pickup : ScriptableObject
{
    public Sprite sprite; //what sprite it shows up as in the UI
    public GameObject prefab;
    public float defaultDuration;

    public enum PickupType
    {
        Passive,
        Active
    }
    public PickupType type;


    [SerializeField] protected PlayerManager player;
    [SerializeField] public float duration { get; protected set; }
    [SerializeField] public float range { get; protected set; }

    public virtual void Setup() { }

    public virtual bool Activate(PlayerManager playerManager)
    {
        player = playerManager;
        return true;
    }

    public virtual void Tick()
    {

    }

    public virtual void Deactivate()
    {

    }

}

MagnetPickup.cs

public class MagnetPickup : Pickup
{
    public StatModifier bouncinessModifier;
    public StatModifier fallDamageModifier;

    float defaultRange = 4f;

    public override void Setup()
    {
        duration = defaultDuration;
        List<StatUpgrade> targettedUpgrades = PlayerData.instance.GetAllUpgradesForPickup(this);
        foreach (StatUpgrade targettedUpgrade in targettedUpgrades)
        {
            foreach (UpgradeLevel level in targettedUpgrade.levels)
            {
                foreach (UpgradeStatModifier modifier in level.modifiers)
                {
                    if (modifier.statType == UpgradeStatModifier.StatType.Duration)
                    {
                        duration += modifier.value;
                    }
                }
            }
        }

        range = defaultRange;
        targettedUpgrades = PlayerData.instance.GetAllUpgradesForPickup(this);
        foreach (StatUpgrade targettedUpgrade in targettedUpgrades)
        {
            foreach (UpgradeLevel level in targettedUpgrade.levels)
            {
                foreach (UpgradeStatModifier modifier in level.modifiers)
                {
                    if (modifier.statType == UpgradeStatModifier.StatType.Range)
                    {
                        range += modifier.value;
                    }
                }
            }
        }
    }

I think I could get away with merging the loops in the MagnetPickup Setup so that it goes through Duration and Range, like

    public override void Setup()
    {
        duration = defaultDuration;
        range = defaultRange;
        List<StatUpgrade> targettedUpgrades = PlayerData.instance.GetAllUpgradesForPickup(this);
        foreach (StatUpgrade targettedUpgrade in targettedUpgrades)
        {
            foreach (UpgradeLevel level in targettedUpgrade.levels)
            {
                foreach (UpgradeStatModifier modifier in level.modifiers)
                {
                    if (modifier.statType == UpgradeStatModifier.StatType.Duration)
                    {
                        duration += modifier.value;
                    }
                    if (modifier.statType == UpgradeStatModifier.StatType.Range)
                    {
                        range += modifier.value;
                    }
                }
            }
        }
    }

but again, I would be writing nearly this same setup for 20+ Pickups that all use slightly different things. Thanks for taking the time and reading this, and I appreciate the help!

\$\endgroup\$
1
  • \$\begingroup\$ Is simply moving logic in Setup to base class Pickup useful? \$\endgroup\$
    – Mangata
    2 days ago

2 Answers 2

0
\$\begingroup\$

Edit: I think I misunderstood your intention. I'll leave my answer here in case it leads somewhere, but I do not consider it a direct answer to your problem. Sorry.


I may have an idea for a different approach.

  1. The Player holds a list of Pickup items.
  2. Each Pickup has a list of upgrades, IUpgrade. See the Magnet example below.
  3. The current upgrade level of each Pickup is saved.
  4. Each upgrade implements an Apply(Player p) method, that modifies a single property on the Player, e.g. UpgradeDamage.

Of course if you have hundreds of items, with hundreds of upgrade levels, this approach is probably slow to execute. Also, I did not take care of any serialization.

Player

using System.Collections.Generic;
using UnityEngine;

public class Player : MonoBehaviour
{
    public int damage;
    public float duration;

    public List<Pickup> items = new List<Pickup>();

    private void Start()
    {
        // Add a new Pickup item for demonstration purposes
        var myItem = ScriptableObject.CreateInstance<Pickup>();
        myItem.upgradeLevel = 3;
        items.Add(myItem);

        // Apply all items
        foreach (var item in items)
        {
            item.ApplyUpgrades(this);
        }
    }
}

abstract Pickup class

using System;
using UnityEngine;

public abstract class Pickup : ScriptableObject
{
    public int upgradeLevel = 1;

    protected abstract IUpgrade[][] upgrades { get; }

    public void ApplyUpgrades(Player player)
    {
        for (var i = 0; i < Math.Min(upgradeLevel, upgrades.Length); i++)
        {
            var levelUpgrades = upgrades[i];

            foreach (var upgrade in levelUpgrades)
            {
                upgrade.Apply(player);
            }
        }
    }
}

Magnet

public class Magnet : Pickup
{
    private static IUpgrade[][] _upgrades =
    {
        // Level 1
        new IUpgrade[]
        {
            new UpgradeDamage(5)
        },
        
        // Level 2
        new IUpgrade[]
        {
            new UpgradeDuration(3f)
        },
        
        // Level 3
        new IUpgrade[]
        {
            new UpgradeDamage(7),
            new UpgradeDuration(5f)
        }
    };

    protected override IUpgrade[][] upgrades => _upgrades;
}

IUpgrade interface

public interface IUpgrade
{
    public void Apply(Player p);
}

UpgradeDamage

public class UpgradeDamage : IUpgrade
{
    private int amount;

    public UpgradeDamage(int amount)
    {
        this.amount = amount;
    }
    
    public void Apply(Player p)
    {
        p.damage += amount;
    }
}

UpgradeDuration

public class UpgradeDuration : IUpgrade
{
    private float amount;

    public UpgradeDuration(float amount)
    {
        this.amount = amount;
    }
    
    public void Apply(Player p)
    {
        p.duration += amount;
    }
}

I might have misunderstood how you want items to upgrade the player, but I think the same pattern could be applied, even if you only want to perform, say, Upgrade Level 3 for instance. Then instead of iterating each upgrade, you would just apply the upgrades at the desired level/index.

\$\endgroup\$
2
  • \$\begingroup\$ Reading your question again, I might have misunderstood what you're trying to achieve. Is it the Pickup items themselves you are trying to upgrade, through other Upgrades the player collects, or how does it work? Sorry, there is a lot of information to digest :) \$\endgroup\$
    – Morten
    2 days ago
  • \$\begingroup\$ I quite like this! For an explanation: A player utilizes pickups that are upgraded through levels. The player simply keeps a reference to the current pickup (1 at a time) and is effected by it. Thanks for the answer! \$\endgroup\$
    – baiomu
    yesterday
0
\$\begingroup\$

Here's a strategy you could try:

using StatType = UpgradeStatModifier.StatType;

public abstract class Pickup : ScriptableObject
{
    // Give a method to fetch a reference to a stat variable by its type:
    public abstract ref float GetStatFloat(StatType stat);

    // No longer needs to be overridden per type.
    public void Setup() {
        // Instead of assigning defaults here, just instantiate "this" mutable
        // powerup instance from a pre-authored asset containing those defaults.

        var upgrades = PlayerData.instance.GetUpgradesFor(this);
        foreach (var upgrade in upgrades) {
            foreach (var level in upgrade.levels) {
                foreach (var modifier in level.modifiers) {
                    // Same code for every float modifier!
                    GetStatFloat(modifier.statType) += modifier.value;
                }
            }
        }
    }
}

public class BoostPickup : Pickup
{
    public float duration;

    public override ref float GetStatFloat(StatType stat) {
        switch(stat) {
            case StatType.Duration: return ref duration;
            default: throw new System.ArgumentException(
              $"'{this.GetType().Name}' does not expose a float stat '{stat}'"
            );
        }
    }
}

You'd still have to implement the GetStatFloat function for each pickup type, which is not ideal, but it's reasonably small. In exchange, your upgrade logic can be completely agnostic about what concrete pickup type or attributes it's working on, so you only have to write that stuff once.

\$\endgroup\$
6
  • \$\begingroup\$ Very interesting way that I didn't think about. Do you mind explaining the point of "ref" in the function? \$\endgroup\$
    – baiomu
    yesterday
  • 1
    \$\begingroup\$ Without this, the float is returned by value, which is great if you just want to read the value, but doesn't help you if you want to set the value. By addingref, we don't just get a copy of the current value of the float, we get a reference to the variable on its containing instance, so if we modify the returned reference, we modify the original. \$\endgroup\$
    – DMGregory
    yesterday
  • \$\begingroup\$ Interesting. What if I have two pickups that use duration? I just define duration in both? \$\endgroup\$
    – baiomu
    12 hours ago
  • \$\begingroup\$ You can do that. It's passing a reference to this specific variable on this specific instance, not to the name "duration", so there's no risk of collision from having similarly-named variables on different classes, if that's what you're concerned about. \$\endgroup\$
    – DMGregory
    11 hours ago
  • \$\begingroup\$ Is there a problem by defining all the attributes that are upgradable in the base Pickup file and then having setup simply applying all upgrades, and just not using them? An example, a magnet uses duration and range, while boots use duration and speed. Is it bad form to just make base pickup have duration, range, and speed, and then just... not use speed in magnet and range in boots? \$\endgroup\$
    – baiomu
    11 hours ago

You must log in to answer this question.

Not the answer you're looking for? Browse other questions tagged .