Harvest Point

Harvest Point

In our game we needed what we call ‘harvest points’. These are points where the player can walk up to and by activating an action, it will burst with loot.

The first thing I try to solve in any kind of code is the trigger code. This is what starts everything off. Whether it’s a button click, an html call, or in this case an action event. Luckily we have a generic action advertisement we can use to trigger this logic. You can read about the action advertisement here.

    private ActionAdvertisement advertisement;

So since we have how we are going to trigger it easily enough. The next thing we need to solve is when we are going to trigger it. The player can’t just harvest constantly at the harvest point. So we need some code to ‘fill up’ the harvest point.

    //minimum number of items when harvest point is full
    [SerializeField]
    private int minItemCap = 3;

    //maximum number of items when harvest point is full
    [SerializeField]
    private int maxItemCap = 5;

    //actual item cap
    private int itemCap;

    //amount of time to fill
    [SerializeField]
    private float refillTimeInMinutes = 5f;

    //time in between each spawn
    private float spawnDelay;
    //time left until next spawn
    private float timeTilNextSpawn;

    private Queue<int> spawnIndexes = new Queue<int>();

Small note: the ‘SerializeField’ attribute is used to allow you to edit the field in the editor.

Now let’s go over our awake logic. At the start of the harvest point we need it to randomly pick an amount to fill to, turn off our harvest affect that displays that we are full, and make sure that we are not advertising to anybody that it’s available.

  private void Awake()
    {
        advertisement = GetComponent<ActionAdvertisement>();
        ResetHarvestPoint();
    }

    private void ResetHarvestPoint()
    {
        itemCap = Random.Range(minItemCap, maxItemCap);
        spawnDelay = (refillTimeInMinutes * 60) / itemCap;
        harvestEffect.SetActive(false);
        advertisement.CurrentlyAdvertising = false;
    }

Then we want to define the update tick since that will kick off everything else

  private void Update()
    {
        timeTilNextSpawn -= Time.deltaTime;
        timeTilNextSpawn = Mathf.Max(0, timeTilNextSpawn);

        bool shouldSpawn = ShouldSpawn();
        if (shouldSpawn)
        {
            AddToSpawnQueue();
        }

    }

    private bool ShouldSpawn()
    {
        bool readyToSpawn = timeTilNextSpawn == 0;

        bool result = !Full && (readyToSpawn || quickReload);
        return result;
    }

Of course for testing & for the first time we load the level, we’ll we won’t want to be waiting an excessive amount of time before being able to harvest. We accomplish that in the code above by adding a ‘quickreload’ boolean defaulted to true to make it so the first ‘fill’ of the spawn is not held up by any delay. This will get turned off permanently the first time it becomes ‘full’

    private bool quickReload = true;
    private bool Full => spawnIndexes.Count == itemCap;

From here we have all the logic to determine WHEN to spawn something, but not any logic to determine WHAT or HOW to spawn it. So first let’s define some variables we’ll need

    //our generalized loop drop   
    [SerializeField]
    private GameObject lootDropPrefab;

    //everything we might spawn
    [SerializeField]
    private WeightedScriptableObject[] spawnOptions;

    private Queue<int> spawnIndexes = new Queue<int>();

And now we can create the function we created in the update “AddToSpawnQueue”

 private void AddToSpawnQueue()
    {
        IEnumerable<KeyValuePair<int, int>> indexWeights = spawnOptions.Select((opt, i) => new KeyValuePair<int, int>(i, opt.Weight));

        //we pick random based on the value/weight and return the key/index
        int index = indexWeights.PickRandom(pair => pair.Value).Key;
        spawnIndexes.Enqueue(index);

        //turn off quick reload once we actually reach the max
        if (spawnIndexes.Count == itemCap)
        {
            quickReload = false;
        }
        timeTilNextSpawn = spawnDelay;

        advertisement.CurrentlyAdvertising = true;

        harvestEffect.SetActive(Full);
    }

So this might require some explaining. Weighted Scriptable Object is a simple class I have defined elsewhere, that simply contains a weight and what scriptable object it references. Defined below.


[Serializable]
public class WeightedScriptableObject
{
    [SerializeField]
    [Range(1, 100)]
    private int _weight;
    public int Weight => _weight;

    [SerializeField]
    private ScriptableObject _config;
    public ScriptableObject Config => _config;
}

This allows us to define different weights to w/e we are spawning inside our inspector in a generic enough way that we can use it in many different places. It’s twin the Weighted Prefab is used in a lot of other places of the code as well.

So going back to our “Add to Spawn Queue” logic.

   IEnumerable<KeyValuePair<int, int>> indexWeights = spawnOptions.Select((opt, i) => new KeyValuePair<int, int>(i, opt.Weight));

The first thing we do here is just flatten the original weighted scriptable objects to just their index & their weight. This way we only need to store the indexes in our spawn list.

We also make sure to turn off quick reload when it’s appropriate

    //turn off quick reload once we actually reach the max
        if (spawnIndexes.Count == itemCap)
        {
            quickReload = false;
        }

As well as make sure we are advertising this action so it can be used

 advertisement.CurrentlyAdvertising = true;

So now we have a queue of what to spawn. We just need the final piece of the puzzle. How to actually spawn it all.

So first let’s define the fields we’ll need

    [SerializeField]
    private Transform spawnPoint;

    [SerializeField]
    private float minSpawnThrowStrength = 2;

    [SerializeField]
    private float maxSpawnThrowStrength = 10;

Now let’s go over the harvest code. This is the code that gets triggered by the advertisement. What actually happens when the player presses a button.

while (spawnIndexes.Count > 0)
        {
            int index = spawnIndexes.Dequeue();
            LootSO selectedConfig = spawnOptions[index].Config as LootSO;

            Transform spawn = SharedPoolManager.Instance.ResourceItemPool.Spawn(lootDropPrefab, spawnPoint.position, spawnPoint.rotation);
            var lootDrop = spawn.GetComponent<LootDrop>();
            lootDrop.SetConfig(selectedConfig);

            Rigidbody spawnRigidBody = spawn.GetComponent<Rigidbody>();

            Vector3 randDirection = Random.insideUnitSphere;
            //always peak at the same point 
            randDirection.y = 1f;          
            var spawnThrowStrength = Random.Range(minSpawnThrowStrength, maxSpawnThrowStrength);
            Vector3 spawnForce = randDirection * spawnThrowStrength;

            spawnRigidBody.AddForce(spawnForce, ForceMode.Impulse);
        }

        ResetHarvestPoint();

So most of that is self explanatory. We dequeue the index. pick the relevant scriptable object. And then we spawn it. Note that we are using a pooling system for our spawning.

Currently we are using the following pooling system
https://assetstore.unity.com/packages/tools/utilities/poolmanager-1010

After spawning it, we get the rigidbody component & apply a random force to it, that is always in the up direction to throw the item. And then when we are done spawning everything we call the same code we set up at the awake function to turn off

And that’s it. We have a fully working Harvest Point. Of course you’ll notice throughout the code something called a ‘harvest effect’. This is flair added to make it so the player can visually see when the harvest point is available. What we ended up doing is added an empty game object with a flower effect on it that we enabled & disabled.

Crystals used: https://assetstore.unity.com/packages/3d/environments/fantasy/translucent-crystals-106274

Mesh effects used: https://assetstore.unity.com/packages/vfx/particles/spells/mesh-effects-67803

And here’s the full code to be able to look at it in a complete manner

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

using UnityEngine;

public class HarvestPoint : MonoBehaviour
{
    //our generalized loop drop   
    [SerializeField]
    private GameObject lootDropPrefab;

    //everything we might spawn
    [SerializeField]
    private WeightedScriptableObject[] spawnOptions;


    //minimum number of items when harvest point is full
    [SerializeField]
    private int minItemCap = 3;

    //maximum number of items when harvest point is full
    [SerializeField]
    private int maxItemCap = 5;

    //actual item cap
    private int itemCap;

    //amount of time to fill
    [SerializeField]
    private float refillTimeInMinutes = 5f;

    //time in between each spawn
    private float spawnDelay;
    //time left until next spawn
    private float timeTilNextSpawn;

    private Queue<int> spawnIndexes = new Queue<int>();




    [SerializeField]
    private Transform spawnPoint;

    [SerializeField]
    private float minSpawnThrowStrength = 2;

    [SerializeField]
    private float maxSpawnThrowStrength = 10;

    private ActionAdvertisement advertisement;

    [SerializeField]
    private GameObject harvestEffect;


    private bool quickReload = true;
    private bool Full => spawnIndexes.Count == itemCap;

    private void Awake()
    {
        advertisement = GetComponent<ActionAdvertisement>();
        ResetHarvestPoint();
    }

    private void ResetHarvestPoint()
    {
        itemCap = Random.Range(minItemCap, maxItemCap);
        spawnDelay = (refillTimeInMinutes * 60) / itemCap;
        harvestEffect.SetActive(false);
        advertisement.CurrentlyAdvertising = false;
    }

    private void Update()
    {
        timeTilNextSpawn -= Time.deltaTime;
        timeTilNextSpawn = Mathf.Max(0, timeTilNextSpawn);

        bool shouldSpawn = ShouldSpawn();
        if (shouldSpawn)
        {
            AddToSpawnQueue();
        }

    }

    private bool ShouldSpawn()
    {
        bool readyToSpawn = timeTilNextSpawn == 0;

        bool result = !Full && (readyToSpawn || quickReload);
        return result;
    }

    private void AddToSpawnQueue()
    {
        IEnumerable<KeyValuePair<int, int>> indexWeights = spawnOptions.Select((opt, i) => new KeyValuePair<int, int>(i, opt.Weight));

        //we pick random based on the value/weight and return the key/index
        int index = indexWeights.PickRandom(pair => pair.Value).Key;
        spawnIndexes.Enqueue(index);

        //turn off quick reload once we actually reach the max
        if (spawnIndexes.Count == itemCap)
        {
            quickReload = false;
        }
        timeTilNextSpawn = spawnDelay;

        advertisement.CurrentlyAdvertising = true;

        harvestEffect.SetActive(Full);
    }

    public void Harvest()
    {
        while (spawnIndexes.Count > 0)
        {
            int index = spawnIndexes.Dequeue();
            LootSO selectedConfig = spawnOptions[index].Config as LootSO;

            Transform spawn = SharedPoolManager.Instance.ResourceItemPool.Spawn(lootDropPrefab, spawnPoint.position, spawnPoint.rotation);
            var lootDrop = spawn.GetComponent<LootDrop>();
            lootDrop.SetConfig(selectedConfig);

            Rigidbody spawnRigidBody = spawn.GetComponent<Rigidbody>();

            Vector3 randDirection = Random.insideUnitSphere;
            //always peak at the same point //vs just never throw it 'down' 
            randDirection.y = 1f;           //Mathf.Abs(randDirection.y);
            var spawnThrowStrength = Random.Range(minSpawnThrowStrength, maxSpawnThrowStrength);
            Vector3 spawnForce = randDirection * spawnThrowStrength;

            spawnRigidBody.AddForce(spawnForce, ForceMode.Impulse);
        }

        ResetHarvestPoint();

    }
}