OK so this might be a little lengthy, but here we go:
- Here is the ‘PlantGrowthHandler.cs’ script. This is responsible for handling how plants grow, apart from the delay that happens when you return (which I want to fix):
using System;
using System.Collections;
using System.Linq;
using GameDevTV.Saving;
using Newtonsoft.Json.Linq;
using RPG.Crafting;
using UnityEngine;
namespace RPG.Farming
{
public class PlantGrowthHandler : MonoBehaviour, ICraftingObserver, IUniqueIdentifierInterface, IJsonSaveable
{
[field: SerializeField] public string UniqueIdentifier { get; private set; }
private ICraftingEventBus eventBus;
private float[] slicePoints;
public GameObject currentPlant;
// TEST
private Recipe currentRecipe;
private GameObject currentInstance;
void Awake()
{
eventBus = GetComponentInParent<ICraftingEventBus>();
eventBus.RegisterObserver(this);
CraftingCompleteEstimationEvent.OnCraftingCompleteEstimation += InstantiateFinalProduct;
}
void OnDestroy()
{
eventBus.DeregisterObserver(this);
CraftingCompleteEstimationEvent.OnCraftingCompleteEstimation -= InstantiateFinalProduct;
}
public void OnCraftingStarted(Recipe recipe)
{
slicePoints = ComputeRandomGrowthSlices(recipe);
StartCoroutine(GrowthRoutine(recipe, recipe.GetCraftDuration()));
SetCurrentRecipe(recipe);
}
public void OnCraftingResumed(Recipe recipe, float remaining)
{
slicePoints = ComputeRandomGrowthSlices(recipe); // Makes sure our Plants don't just grow at the exact same time simultaneously, with a little bit of Randomness
StartCoroutine(GrowthRoutine(recipe, remaining));
SetCurrentRecipe(recipe);
}
private float[] ComputeRandomGrowthSlices(Recipe recipe)
{
var stages = recipe.GetStages.Length - 1; // we don't want to wait for the final product (pickup) so we don't add a slice for it
var duration = recipe.GetCraftDuration();
var avg = duration / stages;
var deviations = new float[stages];
var slices = new float[stages];
// Seed the randomiser for consistent slices
var currentState = UnityEngine.Random.state; // cache the current state
UnityEngine.Random.InitState(transform.position.GetHashCode()); // seed based on the position
// Generate deviations within 30% of the average length
for (int i = 0; i < stages; i++)
{
deviations[i] = avg + (UnityEngine.Random.value * 2f - 1f) * avg * 0.3f;
}
// Restore the randomiser state
UnityEngine.Random.state = currentState; // restore to the cached state
// Normalize deviations to ensure the sum equals duration
var totalDeviation = deviations.Sum();
for (int i = 0; i < stages; i++)
{
slices[i] = deviations[i] * duration / totalDeviation;
}
// Adjust the slices to ensure the sum is exactly duration
const float deviation = 0.001f;
var sumSlices = slices.Sum();
var difference = duration - sumSlices;
// Distribute the difference across the slices
for (int i = 0; i < Mathf.Abs(difference / deviation); i++)
{
int index = i % stages;
if (difference > 0) slices[index] += deviation;
else slices[index] -= deviation;
}
// Accumulate the slices
var accumulator = slices[0];
for (var i = 1; i < slices.Length; i++)
{
accumulator += slices[i];
slices[i] = accumulator;
}
return slices;
}
private IEnumerator GrowthRoutine(Recipe recipe, float remainingDuration)
{
// Determine where to start the timer
var totalDuration = recipe.GetCraftDuration();
var startTime = totalDuration - remainingDuration;
Debug.Log($"Remaining duration is: {remainingDuration}");
// Determine the current stage, based on the elapsed time (if any)
var currentStage = DetermineStage(startTime);
// GameObject currentInstance = null;
if (currentPlant != null)
{
// Instantiate the first phase, only for plants that are currently being grown (empty slots get nothing)
currentInstance = Instantiate(recipe.GetStages[currentStage], transform.position, Quaternion.identity);
}
for (var timer = startTime; timer / totalDuration <= 1f; timer += Time.unscaledDeltaTime /* <-- using 'unscaledDeltaTime' instead of 'deltaTime' because our notification, crafting and all systems work based on real time, so it has to match */)
{
var newStage = DetermineStage(timer);
if (currentStage != newStage)
{
// Update the current stage index
currentStage = newStage;
// Destroy the old instance
Destroy(currentInstance);
if (currentPlant != null)
{
// Instantiate next phase, only for plants that are currently being grown (empty slots get nothing)
currentInstance = Instantiate(recipe.GetStages[currentStage], transform.position, Quaternion.identity);
}
}
yield return null;
}
// Destroying the current instance and instantiating the final product are handled in
// 'InstantiateFinalProduct' below, which is called through events in 'CraftingNotification.CraftingCompleteEstimation()'.
// That way we do not get any early products before the crafting is complete,
// when we restore the game, as it matches exactly when the notification is called
}
public void InstantiateFinalProduct()
{
Destroy(GetCurrentInstance());
if (currentPlant != null)
{
// Instantiate the final phase, only for plants that are being grown (empty slots get nothing)
GetCurrentRecipe().GetResult().Item.SpawnPickup(transform.position, 1 /* <-- one output per farming slot */);
currentPlant = null;
}
}
private int DetermineStage(float time)
{
var stage = Array.BinarySearch(slicePoints, time); // Blazingly-fast solution of determine the growth stage of the Farming Plant
if (stage < 0)
{
stage = ~stage;
}
return stage;
}
public GameObject GetCurrentPlant()
{
return currentPlant;
}
public void SetCurrentPlant(GameObject currentPlant)
{
this.currentPlant = currentPlant;
}
public JToken CaptureAsJToken()
{
Debug.Log($"Capturing from PlantGrowthHandler {UniqueIdentifier}");
return UniqueIdentifier;
}
public void RestoreFromJToken(JToken state)
{
Debug.Log($"Restoring from PlantGrowthHandler {state.ToString()}");
}
public GameObject GetCurrentInstance()
{
return currentInstance;
}
public void SetCurrentInstance(GameObject currentInstance)
{
this.currentInstance = currentInstance;
}
public Recipe GetCurrentRecipe()
{
return currentRecipe;
}
public void SetCurrentRecipe(Recipe currentRecipe)
{
this.currentRecipe = currentRecipe;
}
}
}
For the time being, the recipe and the current instances have been moved to public, as I try figure out how to fix this issue
- Here is ‘CraftingNotification.cs’, which has ‘GetNumberOfOccupiedSlots()’:
using System.Collections;
using RPG.Crafting;
using RPG.Farming;
using RPG.Skills;
using UnityEngine;
namespace RPG.CraftingNotifications
{
// This class is responsible for firing a Notification to the player when crafting is done, and he's not around
// (it's been refined to avoid a 3-way couple of 'CraftingNotification.cs', 'CraftingTable.cs' and 'PlantGrowthHandler.cs')
public class CraftingNotification : MonoBehaviour, ICraftingObserver
{
private ICraftingEventBus eventBus;
private CurrentQuantityHolder currentQuantityHolder;
private int numberOfOccupiedSlots;
void Awake()
{
eventBus = GetComponentInParent<ICraftingEventBus>();
eventBus.RegisterObserver(this);
}
void OnEnable()
{
currentQuantityHolder = FindObjectOfType<CurrentQuantityHolder>(true); // Find the 'currentQuantityHolder' when you spawn the farming patch (hence 'OnEnable()'), so we can use its value to get the correct xp
}
void OnDestroy()
{
eventBus.DeregisterObserver(this);
}
public void OnCraftingStarted(Recipe recipe)
{
StartCoroutine(CraftingCompleteEstimation(recipe, recipe.GetCraftDuration()));
numberOfOccupiedSlots = GetNumberOfOccupiedSlots();
}
public void OnCraftingResumed(Recipe recipe, float remainingTime)
{
StopAllCoroutines();
StartCoroutine(CraftingCompleteEstimation(recipe, remainingTime));
numberOfOccupiedSlots = GetNumberOfOccupiedSlots();
}
private IEnumerator CraftingCompleteEstimation(Recipe recipe, float delay)
{
yield return new WaitForSecondsRealtime(delay);
// FARMING ONLY, as farming is the only skill that will have 'recipe.GetStages' not be equal to zero
// (Other Crafting-UI Based skills will be covered in 'CraftingSystem.cs')
if (recipe.GetStages != null && recipe.GetStages.Length != 0)
{
GameObject.FindWithTag("Player").GetComponent<SkillExperience>().GainExperience(recipe.GetRequiredSkill(), recipe.GetXPReward() * numberOfOccupiedSlots);
// Find a way to identify only the farming patch/crafting table this caller has, instead of everyone involved
CraftingCompleteEstimationEvent.InvokeOnCraftingCompleteEstimation(); // Called from 'PlantGrowthHandler.InstantiateFinalProduct' event subscription, so we can instantiate the final product when the notification is invoked
}
MalbersAnimations.InventorySystem.NotificationManager.Instance.OpenNotification($"Crafting Complete:\n{recipe.name}");
}
private int GetNumberOfOccupiedSlots()
{
// Iterate through all Plant Growth Handler kids of this script, and
// any occupied ones with plants will be added, so we can properly multiply
// the experience
int totalOccupiedSlots = 0;
foreach (var slot in GetComponentsInChildren<PlantGrowthHandler>())
{
if (slot.GetCurrentPlant())
{
totalOccupiedSlots++;
}
}
return totalOccupiedSlots;
}
}
}
‘GetNumberOfOccupiedSlots()’ checks how many slots have a ‘currentPlant’ in ‘PlantGrowthHandler.cs’, and counts them as an ‘occupied slot’
- I reversed the changes you suggested last night just to be safe until the morning, but here is the current ‘OnCraftingCompleteEstimation’ event:
using System;
public static class CraftingCompleteEstimationEvent
{
public static event Action OnCraftingCompleteEstimation;
public static void InvokeOnCraftingCompleteEstimation()
{
OnCraftingCompleteEstimation?.Invoke();
}
}
- Here is the ‘CraftingEventBus.cs’, which has something to do with ‘CraftingStarted’ and ‘CraftingResumed’:
using System.Collections;
using System.Collections.Generic;
using RPG.Crafting;
using UnityEngine;
namespace RPG.CraftingNotifications
{
// An 'Event Bus' is a central bus that allows communication between two scripts,
// without the need of them coupling each other
public class CraftingEventBus : MonoBehaviour, ICraftingEventBus
{
private List<ICraftingObserver> observers = new List<ICraftingObserver>();
public void CraftingStarted(Recipe recipe)
{
foreach (var observer in observers)
{
observer.OnCraftingStarted(recipe);
}
}
public void CraftingResumed(Recipe recipe, float remainingTime)
{
foreach (var observer in observers)
{
observer.OnCraftingResumed(recipe, remainingTime);
}
}
public void RegisterObserver(ICraftingObserver observer)
{
if (observers.Contains(observer)) return;
observers.Add(observer);
}
public void DeregisterObserver(ICraftingObserver observer)
{
observers.Remove(observer);
}
}
}
and 5. Here is my crafting saving system, in case this is needed. The main problem I have is the restoring there has some inaccuracies with the timing, so I want the plant to be created when the notification is out, as the notification does not seem to be having this kind of problem:
JToken IJsonSaveable.CaptureAsJToken()
{
// Save the state, start time, recipe, and output (if any)
var saveData = new CraftingTableSaveData
{
CraftingState = CurrentState,
CraftingStartTime = craftingStartTime,
CraftingFailed = craftingFailed,
FailedAtPercentage = failedAtPercentage
};
if (CurrentRecipe != null)
{
saveData.RecipeID = CurrentRecipe.GetRecipeID();
}
if (CraftedOutput != null && CraftedOutput.Item != null)
{
saveData.OutputItemID = CraftedOutput.Item.GetItemID();
saveData.OutputAmount = CraftedOutput.Amount;
}
// GET THE CURRENT QUANTITY OF PLANTS THAT ARE BEING PLANTED, SO WE CAN RESTORE THEM WHEN WE RETURN TO THE GAME:
var currentQuantity = GetComponentsInChildren<PlantGrowthHandler>().Count(handler => handler.GetCurrentPlant() != null);
saveData.CurrentQuantity = currentQuantity;
return JToken.FromObject(saveData);
}
void IJsonSaveable.RestoreFromJToken(JToken state)
{
// Restore the state, start time, recipe and output (if any)
var saveData = state.ToObject<CraftingTableSaveData>();
craftingStartTime = 0;
CurrentRecipe = default;
CurrentState = saveData.CraftingState;
currentAction = IdleAction;
craftingFailed = saveData.CraftingFailed;
failedAtPercentage = saveData.FailedAtPercentage;
// If we saved an output item, restore it
if (!string.IsNullOrWhiteSpace(saveData.OutputItemID) && saveData.OutputAmount > 0)
{
CraftedOutput = new CraftedItemSlot(InventoryItem.GetFromID(saveData.OutputItemID), saveData.OutputAmount);
}
// If we are idle, we are done here
if (CurrentState == CraftingState.Idle)
{
return;
}
// If we are here, we were crafting something the last time we got saved
// Update the table state to continue crafting
craftingStartTime = saveData.CraftingStartTime;
CurrentRecipe = Recipe.GetFromID(saveData.RecipeID);
// Plant the plants that are supposed to be there, based on where they are in time:
if (CurrentRecipe != null && saveData.CurrentQuantity > 0)
{
for(int i = 0; i < saveData.CurrentQuantity; i++)
{
var availableSlots = GetComponentsInChildren<PlantGrowthHandler>().Where(handler => handler.GetCurrentPlant() == null);
var firstAvailableSlot = availableSlots.First();
firstAvailableSlot.SetCurrentPlant(CurrentRecipe.GetResult().Item.GetPickup().gameObject);
}
}
currentAction = CraftingAction;
// Notification Trigger, in case the player was gone when crafting was pending, and now he's back:
var elapsedTime = (float) timeKeeper.GetGlobalTime() - (float) craftingStartTime;
// CraftingResumed?.Invoke(CurrentRecipe.GetCraftDuration() - elapsedTime); // Replaced by a more generic 'Crafting Notification' System below
eventBus.CraftingResumed(CurrentRecipe, CurrentRecipe.GetCraftDuration() - elapsedTime); // Allows proper notification timing when we return to the game
}
[Serializable]
struct CraftingTableSaveData
{
public CraftingState CraftingState;
public double CraftingStartTime;
public string RecipeID;
public string OutputItemID;
public int OutputAmount;
public bool CraftingFailed;
public float FailedAtPercentage;
public int CurrentQuantity;
}
and 6. This one is crucial for all these systems to work together (except that it doesn’t have the ‘GameObject currentInstance’, I just added that to try and compensate for what I’m trying to do):
using RPG.Crafting;
using UnityEngine;
public interface ICraftingObserver
{
void OnCraftingStarted(/* GameObject currentInstance, */ Recipe recipe);
void OnCraftingResumed(/* GameObject currentInstance, */ Recipe recipe, float remainingTime);
}
and 7, the Crafting Event Bus original interface:
using RPG.Crafting;
public interface ICraftingEventBus
{
void CraftingStarted(Recipe recipe);
void CraftingResumed(Recipe recipe, float remainingTime);
void RegisterObserver(ICraftingObserver observer);
void DeregisterObserver(ICraftingObserver observer);
}
If you need anything else, please let me know
Just a heads-up, the way my farming works, is we got the ‘CraftingTable.cs’ (which does the farming) on the top of the hierarchy, and each farming slot available on the farming patch has a ‘PlantGrowthHandler.cs’ script on it, which handles each plant individually
I’m slowly thinking of making a small change and making the slots themselves as crafting tables, so I can plant each plant individually. I admit, I should’ve thought of this earlier. This was a big mistake from my side!
That ‘PlantGrowthHandler.cs’ script has a ‘currentPlant’ variable that gets occupied when farming is occuring somewhere. Once it’s empty, the pickup spawns and that slot will be available for use again
And the more I try to dive into this on my own, the more dangerous it gets. This is territory I’d rather not be doing on my own without a little bit of guidance to be honest, and I’ve learned the hard way to never move on to new ideas until your previous ones are polished