[Just a heads-up, there’s a ton of custom code here, hence why it’s a “Talk”. If you’re still in the RPG Course or what not, you may want to hard-pass on this post]
Hello friends, hope you’re all having a great day. This one is a bit of custom code, but I am having some serious problems trying to figure this one out
Not too long ago, I started working on making crafting systems saveable when crafting tables are built and destroyed, and frankly speaking I got about 80% of all systems to work in one fix… except my own Farming variant, since Farming uses the same UI but has a lot of differences from the other systems
So, to keep it short, here’s my custom script where most of my issues still persist, and I still can’t figure it out:
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, IJsonSaveable
{
private ICraftingEventBus eventBus;
private float[] slicePoints;
private CraftingQuantity craftingQuantity;
public GameObject currentPlant;
// TESTS - DELETE IF FAILED
private GameObject currentInstance;
private int currentStage;
private string currentRecipeID; // (TEST - DELETE THIS VARIABLE ENTIRELY IF IT FAILS ITS JOB!)
void Awake()
{
eventBus = GetComponentInParent<ICraftingEventBus>();
eventBus.RegisterObserver(this);
}
void OnDestroy()
{
eventBus.DeregisterObserver(this);
}
public void OnCraftingStarted(Recipe recipe)
{
// craftingQuantity = FindObjectOfType<CraftingQuantity>(); // when you start crafting (I can't get it to link to 'NotifyCrafting' without resulting in a circular dependency, so this is my next best option)
slicePoints = ComputeRandomGrowthSlices(recipe);
currentRecipeID = recipe.GetRecipeID(); // TEST - DELETE IF FAILED
StartCoroutine(GrowthRoutine(recipe, recipe.GetCraftDuration()));
}
public void OnCraftingResumed(Recipe recipe, float remaining)
{
// craftingQuantity = FindObjectOfType<CraftingQuantity>(); // when you resume crafting, from a loaded save scene (I can't get it to link to 'NotifyCrafting' without resulting in a circular dependency, so this is my next best option)
slicePoints = ComputeRandomGrowthSlices(recipe); // Makes sure our Plants don't just grow at the exact same time simultaneously, with a little bit of Randomness
currentRecipeID = recipe.GetRecipeID(); // TEST - DELETE IF FAILED
StartCoroutine(GrowthRoutine(recipe, remaining));
}
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 duration)
{
// Determine where to start the timer
var totalDuration = recipe.GetCraftDuration();
var startTime = totalDuration - duration;
// Determine the current stage, based on the elapsed time (if any)
// var currentStage = DetermineStage(startTime); // TEST BELOW
currentStage = DetermineStage(startTime);
// GameObject currentInstance = null; // TEST BELOW
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;
}
// Destroy the plant
Destroy(currentInstance);
if (currentPlant != null)
{
// Instantiate the final phase, only for plants that are being grown (empty slots get nothing)
recipe.GetResult().Item.SpawnPickup(transform.position, 1 /* <-- one output per farming slot */);
currentPlant = null;
craftingQuantity = FindObjectOfType<CraftingQuantity>(true); // Regardless of whether you're crafting or not (hence the word 'true' for when it's inactive), you'll need to refind that value and re-update it when crafting is done, otherwise you'll have a mathematical bug in the quantity UI the first time after the previous farm is done
craftingQuantity.SetMaxQuantity(craftingQuantity.GetMaxQuantity() + 1 /* <-- each script holder can only output one pickup, hence +1 only */);
}
}
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()
{
JToken saveData = JToken.FromObject(new SaveData
{
CurrentPlantName = currentPlant != null ? currentPlant.name : null, // no plant, no name
CurrentStage = currentStage,
RecipeID = currentRecipeID
});
Debug.Log($"Captured values: {saveData}");
return saveData;
}
public void RestoreFromJToken(JToken state)
{
var data = state.ToObject<SaveData>();
currentStage = data.CurrentStage;
currentRecipeID = data.RecipeID;
Debug.Log($"Restored data: Current Plant Name = {data.CurrentPlantName}, Current Stage = {currentStage}, RecipeID = {data.RecipeID}");
if (!string.IsNullOrEmpty(data.CurrentPlantName))
{
GameObject plantPrefab = LoadPlantPrefabByName(data.CurrentPlantName);
if (plantPrefab != null)
{
Debug.Log($"Restore from JToken: Loaded Plant Prefab = {plantPrefab.name}");
currentPlant = plantPrefab;
currentInstance = Instantiate(currentPlant, transform.position, Quaternion.identity);
Debug.Log($"Restore from JToken: Instantiated Plant Prefab = {currentInstance.name}");
}
else
{
Debug.Log($"RestoreFromJToken failed to load a plant prefab with name {data.CurrentPlantName}");
}
}
else
{
Debug.Log($"RestoreFromJToken: No plant name provided");
}
}
private GameObject LoadPlantPrefabByName(string name)
{
return Resources.Load<GameObject>($"FarmingRecipes/{name}");
}
[Serializable]
private struct SaveData
{
public string CurrentPlantName;
public int CurrentStage;
public string RecipeID;
}
}
}
This is my ‘PlantGrowthHandler.cs’ script, and through other changes it manages to plant plants on the field based on what you want to plant, the current instance, how much time has passed and so on and so forth.
But the saving system for this is an absolute mess, and the values simply do not recover as expected… at all, and that’s where I’ll need help
So I’m trying to save (and load) the following variables, and for the following reasons:
- ‘currentPlant’. I want to save and restore the current plant so that the script holder can indicate that this slot is taken, because that’s how the farming system knows whether this slot is taken or available to farm at, from ‘CraftingTable.cs’:
for (int i = 0; i < craftingQuantity.GetCurrentQuantity(); i++)
{
// Plant quantity based on what the 'GetCurrentQuantity()' has available,
// which is controlled in 'CraftingSystem.OnCraftingInteraction()', and the
// value is also updated below, after you hit the 'Craft' button on the UI
var availableSlots = GetComponentsInChildren<PlantGrowthHandler>().Where(handler => handler.GetCurrentPlant() == null);
var firstAvailableSlot = availableSlots.First();
firstAvailableSlot.SetCurrentPlant(recipe.GetResult().Item.GetPickup().gameObject); // Just a way to indicate that this slot is occupied
}
- ‘CurrentInstance’ (or ‘currentStage’, preferably currentStage), because this is what tells this slot which instance (i.e: stage) it was at, before the saving happened, indicating where the plant’s growth level was at, so it can instantiate the correct stage. For reference, it gets the stage prefabs from ‘Recipe.cs’, mainly these two lines of code:
// FARMING (used in 'PlantGrowthHandler.cs')
[SerializeField] GameObject[] stagePrefabs;
public GameObject[] GetStages => stagePrefabs;
- This is a bit of an extremely odd problem, but when the plants move on to the next stage, they all (again, not all points are meant to be there in my testing…) vanish off the farming field, and I can’t explain why. Again, the stages are accessed from the two lines of code beneath point 2 above, these two lines:
// FARMING (used in 'PlantGrowthHandler.cs')
[SerializeField] GameObject[] stagePrefabs;
public GameObject[] GetStages => stagePrefabs;
So far, what I’ve tried to do, is create a serialized class to store the data, and save and restore that. The big problem I have, and I can’t explain why, is that the capturing captures the correct data, but the restore returns everything as null. It returns no “CurrentPlantName” (it comes back empty, literally), and the recipe id returns as null (so I can’t access the stage prefabs now, which means I can’t tell the plant what stage to move on next to), and the current stage value returns as zero, regardless of whether it’s at stage 0 or not (so regardless of how far they’re in the planting, they look like infant plants. No good!).
And the worst part is, even the slots that are carrying the ‘PlantGrowthHandler.cs’ script, but have no ‘currentPlant’ on them (i.e: they are free to use) somehow get the same values as the plant slot that is occupied, and now everyone who was free has the same plant (and since I’m currently testing for only one plant variant, I can’t tell yet how multiple plant variants will affect this). Point is, I only want to restore the correct data for the slot that was occupied, not all of them
Here’s the code I’m talking about, in ‘PlantGrowthHandler.cs’:
public JToken CaptureAsJToken()
{
JToken saveData = JToken.FromObject(new SaveData
{
CurrentPlantName = currentPlant != null ? currentPlant.name : null, // no plant, no name
CurrentStage = currentStage,
RecipeID = currentRecipeID
});
Debug.Log($"Captured values: {saveData}");
return saveData;
}
public void RestoreFromJToken(JToken state)
{
var data = state.ToObject<SaveData>();
currentStage = data.CurrentStage;
currentRecipeID = data.RecipeID;
Debug.Log($"Restored data: Current Plant Name = {data.CurrentPlantName}, Current Stage = {currentStage}, RecipeID = {data.RecipeID}");
if (!string.IsNullOrEmpty(data.CurrentPlantName))
{
GameObject plantPrefab = LoadPlantPrefabByName(data.CurrentPlantName);
if (plantPrefab != null)
{
Debug.Log($"Restore from JToken: Loaded Plant Prefab = {plantPrefab.name}");
currentPlant = plantPrefab;
currentInstance = Instantiate(currentPlant, transform.position, Quaternion.identity);
Debug.Log($"Restore from JToken: Instantiated Plant Prefab = {currentInstance.name}");
}
else
{
Debug.Log($"RestoreFromJToken failed to load a plant prefab with name {data.CurrentPlantName}");
}
}
else
{
Debug.Log($"RestoreFromJToken: No plant name provided");
}
}
private GameObject LoadPlantPrefabByName(string name)
{
return Resources.Load<GameObject>($"FarmingRecipes/{name}");
}
[Serializable]
private struct SaveData
{
public string CurrentPlantName;
public int CurrentStage;
public string RecipeID;
}
@Brian_Trotter (when you’re available) or @bixarrio (when you’re available) or anyone else, if anyone has any inputs on this or can help me in anyway, please let me know. I know this is a little way too off topic, but I don’t know what else to do or who to ask, and it’s been bothering me for a bit now. In the meanwhile if I get anything working, I’ll update this post
And that’s why you test everything early on… I was not expecting that kind of chaos at all!