Within the RPG course, our saving system deals very well with characters that are already in the scene when the game starts.
What it doesn’t deal with well is characters who are spawned into the scene dynamically. These may be characters spawned by a trigger, say walking into the town square… or characters who only appear after a quest is accepted or any number of other conditions.
What I’ve put together here is a framework for tracking and saving procedurally spawned characters. This will not deal with the initial spawn of the character, except to provide a function that can be called to spawn a character.
Note that through this post, I will include both the classic saving system presented in the course, as well as the JsonSavingSystem intended as a replacement for the classic saving system.
DynamicEntity
Our normal saving system gathers all SaveableEntities in the scene, then each SaveableEntity is responsible for saving it's own characters. It restores those characters by looking for every SaveableEntity and restoring them from what was gathered in CaptureState. The problem with this approach is that for characters that are procedurally generated, while their save data will be captured, when they are re-incorporated into the scene, they may not be there to be restored.For this, we need a new entity type, the DynamicEntity. This script should be on your dynamic character’s prefab instead of SaveableEntity. (If you intend to use the same character for both fixed entities and dynamic ones, create a Prefab variant of your fixed entity, and in the Variant remove the SaveableEntity and add this one.
DynamicEntity.cs
using System.Collections.Generic;
using GameDevTV.Saving;
using Newtonsoft.Json.Linq;
using UnityEngine;
namespace RPG.Dynamic
{
public class DynamicEntity : MonoBehaviour
{
// Called when entity is destroyed, allowing cleanup of entities list
public event System.Action<DynamicEntity> entityDestroyed;
public object CaptureState()
{
Dictionary<string, object> state = new Dictionary<string, object>();
foreach (ISaveable saveable in GetComponents<ISaveable>())
{
state[saveable.GetType().ToString()] = saveable.CaptureState();
}
return state;
}
public void RestoreState(object state)
{
Dictionary<string, object> stateDict = (Dictionary<string, object>)state;
foreach (ISaveable saveable in GetComponents<ISaveable>())
{
string typeString = saveable.GetType().ToString();
if (stateDict.ContainsKey(typeString))
{
saveable.RestoreState(stateDict[typeString]);
}
}
}
public JToken CaptureAsJToken()
{
JObject state = new JObject();
foreach (IJsonSaveable saveable in GetComponents<IJsonSaveable>())
{
state[saveable.GetType().ToString()] = saveable.CaptureAsJToken();
}
return state;
}
public void RestoreFromJToken(JToken state)
{
if (state is JObject stateDict)
{
foreach (IJsonSaveable saveable in GetComponents<IJsonSaveable>())
{
string typeString = saveable.GetType().ToString();
if (stateDict.ContainsKey(typeString))
{
saveable.RestoreFromJToken(stateDict[typeString]);
}
}
}
}
private void OnDestroy()
{
entityDestroyed?.Invoke(this);
}
}
}
There’s not a great deal to this script, and it was quite literally taken from the SaveableEntity and JsonSaveableEntity classes.
DynamicCharacterConfiguration
Another tricky aspect of dynamic characters is knowing what characters to spawn in when RestoreState() has been called. The easiest way to manage this is to use a setup similar to our InventoryItem, using a ScriptableObject with an ID which we can retrieve from the resources folder.DynamicCharacterConfiguration.cs
using System.Collections.Generic;
using UnityEngine;
namespace RPG.Dynamic
{
[CreateAssetMenu(fileName = "Dynamic Character Config", menuName = "Dynamic/Character Configuration", order = 0)]
public class DynamicCharacterConfiguration : ScriptableObject
{
[SerializeField] private string characterID = "";
[SerializeField] private GameObject characterPrefab;
/// <summary>
/// Spawns in the characterPrefab with the supplied transform as the owner. The calling object
/// is responsible for placing the character and restoring it's state if applicable.
/// </summary>
/// <param name="owner">Owner of the resulting entity, generally the calling class.</param>
/// <returns>null if no prefab or prefab does not contain a DynamicEntity</returns>
public DynamicEntity Spawn(Transform owner)
{
if (characterPrefab == null) return null;
GameObject go = Instantiate(characterPrefab, owner);
DynamicEntity entity = go.GetComponent<DynamicEntity>();
return entity;
}
public string CharacterID => characterID;
private void OnValidate()
{
// prevent prefabs from being assigned that do not have a DynamicEntity on them.
if (characterPrefab != null && characterPrefab.TryGetComponent(out DynamicEntity dynamicEntity))
{
characterPrefab = null;
}
//Ensures there is always a characterID
if (string.IsNullOrEmpty(characterID))
{
characterID = System.Guid.NewGuid().ToString();
lookUp = null;
}
}
private static Dictionary<string, DynamicCharacterConfiguration> lookUp;
/// <summary>
/// Gets the configuration from Resources with the given CharacterID. In order to be retrieved,
/// the DynamicConfiguration must be in a folder named Resources (or a subfolder in a Resources folder).
/// </summary>
/// <param name="id">CharacterID to be retrieved</param>
/// <returns>null if string is empty or id is not found</returns>
public static DynamicCharacterConfiguration GetFromID(string id)
{
if (string.IsNullOrEmpty(id)) return null;
if (lookUp == null)
{
lookUp = new Dictionary<string, DynamicCharacterConfiguration>();
foreach (var configuration in Resources.LoadAll<DynamicCharacterConfiguration>(""))
{
if (lookUp.ContainsKey(configuration.characterID))
{
Debug.LogWarning($"{configuration} and {lookUp[configuration.characterID]} have the same characterID");
}
else
{
lookUp[configuration.characterID] = configuration;
}
}
}
if (lookUp.ContainsKey(id)) return lookUp[id];
return null;
}
}
}
OnValidate ensures that we only have DynamicEntities as our prefabs, rejecting an attempt to put any other type of prefab in the location (but still allowing us to use the object picker, which won’t work if you make the field a DynamicEntity). It also ensures that the ID field is filled in.
Spawn instantiates the prefab, returning a link to the instantiated instance. This is used both by whatever method you use to spawn in a character, and the Dynamic Saving component.
For each type of character you want to be spawned dynamically, you’ll need to create a DynamicCharacterConfiguration, set it up, and put it in a Resources Folder, much like you would with an Inventory Item.
Dynamic Saving
The final piece of the puzzle is in the DynamicSaving component. This component will go on a GameObject in your scene.DynamicSaving.cs
using System.Collections.Generic;
using GameDevTV.Saving;
using Newtonsoft.Json.Linq;
using UnityEngine;
namespace RPG.Dynamic
{
public class DynamicSaving : MonoBehaviour, ISaveable, IJsonSaveable
{
private Dictionary<DynamicEntity, DynamicCharacterConfiguration> entities = new ();
/// <summary>
/// Registers an entity with the DynamicSaving component. Will automatically subscribe to the
/// DynamicEntity's OnDestroy if the Entity is destroyed.
/// </summary>
/// <param name="entity">An instantiated entity within the scene</param>
/// <param name="configuration">DynamicCharacterConfiguration assigned to thsi character</param>
public void RegisterDynamicEntity(DynamicEntity entity, DynamicCharacterConfiguration configuration)
{
entities[entity] = configuration;
entity.entityDestroyed += dynamicEntity =>
{
if (entities.ContainsKey(dynamicEntity))
{
entities.Remove(dynamicEntity);
}
};
}
/// <summary>
/// Spawns a Dynamic Entity using the supplied DynamicCharacterConfiguration and registers it.
/// </summary>
/// <param name="configuration">Configuration to use.</param>
/// <param name="position"></param>
/// <param name="eulerAngles"></param>
/// <returns>null if entity cannot be spawned.</returns>
public DynamicEntity CreateDynamicEntity(DynamicCharacterConfiguration configuration, Vector3 position, Vector3 eulerAngles)
{
if (configuration == null) return null;
DynamicEntity entity = configuration.Spawn(transform);
if (entity == null) return null;
entity.transform.position = position;
entity.transform.eulerAngles = eulerAngles;
RegisterDynamicEntity(entity, configuration);
return entity;
}
public object CaptureState()
{
Dictionary<string, object> state = new();
foreach (KeyValuePair<DynamicEntity,DynamicCharacterConfiguration> pair in entities)
{
if(pair.Key==null) continue; //Should be impossible
state[pair.Value.CharacterID] = pair.Key.CaptureState();
}
return state;
}
public void RestoreState(object state)
{
if (state is Dictionary<string, object> stateDict)
{
foreach (KeyValuePair<string,object> pair in stateDict)
{
DynamicCharacterConfiguration config = DynamicCharacterConfiguration.GetFromID(pair.Key);
if (config == null) continue;
var character = CreateDynamicEntity(config, transform.position, transform.eulerAngles);
if (character)
{
character.RestoreState(pair.Key);
}
}
}
}
public JToken CaptureAsJToken()
{
JObject state = new();
foreach (KeyValuePair<DynamicEntity,DynamicCharacterConfiguration> pair in entities)
{
if(pair.Key==null) continue; //Should be impossible
state[pair.Value.CharacterID] = pair.Key.CaptureAsJToken();
}
return state;
}
public void RestoreFromJToken(JToken state)
{
if (state is JObject stateDict)
{
foreach (KeyValuePair<string,JToken> pair in stateDict)
{
DynamicCharacterConfiguration config = DynamicCharacterConfiguration.GetFromID(pair.Key);
if (config == null) continue;
var character = CreateDynamicEntity(config, transform.position, transform.eulerAngles);
if (character)
{
character.RestoreFromJToken(pair.Key);
}
}
}
}
}
}
This component should be paired with a SaveableEntity (or JsonSaveableEntity), which will be found by the Saving System. The entity finds the DynamicSaving as an ISaveable, and it captures and restores the states of all DynamicEntities.
When you want to spawn a dynamic character, you can do it through the DynamicCharacterConfiguration and register it with the DynamicSaving manually with RegisterDynamicEntity(), or you can simply call DynamicSaving’s CreateDynamicEntity with the DynamicCharacterConfiguration and the position/rotation you wish to have applied to the new character.
When the SavingSystem gets to the DynamicSaving, it’s CaptureState (or CaptureAsJToken) gathers all of the entities and saves them by the string id of the DynamicCharacterConfiguration and gets the CaptureState from that entity. When restoring, it simply calls CreateDynamicEntity for each configuration in it’s Dictionary and passes the saved state on to the newly created entity.
This system isn’t exclusively tied to the RPG course. As long as you’re using the SavingSystem (or preferably the JsonSavingSystem), you can use these three classes to spawn procedural elements into your scene and save/restore them.
One caveat: This system assumes a Restore from a clean version of the scene. It will produce bugs like duplicating the dynamic characters if you L) oad using the L key in the RPG game.