I’m making fairly extensive use of ScriptableObject as an in-editor tool, by making both NPC and Quest “container” classes that extend ScriptableObject, allowing me to manually build characters and complete quests in-editor. I don’t actually serialize anything in my ScriptableObjects, they’re just there to serve as a poor man’s data editor; the first time the player starts a new game, I want the system to load in all of my player/quest/other data from the appropriate objects, then serialize that data in whatever savegame format I designate.
This workflow is pretty nifty for creating content, since I can create a single folder inside Resources/Characters for every unique NPC, and use that folder to store dialogue portraits, spawn/loot tables, and anything else relevant to that character; so long as the system can locate each character’s base CharacterScriptableObject, it knows how to put everything together in a serializable format.
However, this same format is absolutely terrible for actually getting that information to the system. Resources.FindObjectsOfTypeAll will only return the scriptable objects only in memory, which leaves me iterating through every object in the Resources folder and trying to cast it as a CharacterSO or whatever other data I want, and that is terrible workflow. The obvious alternative seems to be creating some kind of ScriptableReferences script whose one and only job is to retain a manual reference to every single scriptableobject the system might want to access, but that requires tons of manual setup and feels nearly as daft.
So is there something I’m missing, or is brute-force iteration the necessary tradeoff to maintaining my “Dump things in a resource folder and forget about them” approach to content creation?
CharacterSO[] allchars = Resources.LoadAll<CharacterSO>("Characters"); is what you’re looking for, though it’s pretty slow once you get a lot of characters. What I like to do have an “index” asset, that only stores the paths to each character and load that first. Something like: (Okay this example got way out of hand as I type it up)
Indexes any asset with naming conventions id_nameofasset, id’s must be unique and are shared between assets. You can then pull them by using ResourcesIndex.GetAsset(id);
ResourcesIndex.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
// naming convention is id_whateverGoesHere, must be unique
public class ResourcesIndex : ScriptableObject
{
private const string STORAGE_PATH = "ResourcesIndex";
private static Dictionary<int, ResourceAsset> assetsDict;
#if UNITY_EDITOR
// add types to be indexed here
private static Dictionary<string, System.Type> typesToIndex = new Dictionary<string, System.Type>
{
// path in resources folder, type of UnityEngine.Object
{ "Data", typeof(TextAsset) },
//{ "Quests", typeof(QuestSO) },
//{ "Characters", typeof(CharacterSO) },
// etc... any types you want to index
};
[UnityEditor.InitializeOnLoadMethod]
private static void BuildMap()
{
var index = Resources.Load<ResourcesIndex>(STORAGE_PATH);
if (index == null)
{
index = CreateInstance<ResourcesIndex>();
UnityEditor.AssetDatabase.CreateAsset(index, "Assets/Resources/" + STORAGE_PATH + ".asset");
}
index.assets = new List<ResourceAsset>();
foreach (var kvp in typesToIndex)
{
var all = Resources.LoadAll(kvp.Key, kvp.Value);
for (int i = 0; i < all.Length; i++)
{
// naming convention is id_whateverGoesHere, must be unique
Object o = all[i];
int id = -1;
string[] split = o.name.Split('_');
if (int.TryParse(split[0], out id) == false)
{
Debug.LogErrorFormat("Invalid naming convention for asset {0}", o.name);
continue;
}
index.assets.Add(new ResourceAsset()
{
id = id,
assetPath = GetRelativeResourcePath(UnityEditor.AssetDatabase.GetAssetPath(o)),
});
}
}
UnityEditor.EditorUtility.SetDirty(index);
}
public static string GetRelativeResourcePath(string path)
{
System.Text.StringBuilder stringBuilder = new System.Text.StringBuilder();
if (path.Contains("/Resources/"))
{
string[] rSplit = path.Split(new string[] { "/Resources/" }, System.StringSplitOptions.RemoveEmptyEntries);
string[] split = rSplit[1].Split('.');
for (int j = 0; j < split.Length - 1; j++)
{
stringBuilder.Append(split[j]);
if (j < split.Length - 2)
stringBuilder.Append('/');
}
return stringBuilder.ToString();
}
return path;
}
private void OnValidate()
{
if (UnityEditor.EditorApplication.isPlayingOrWillChangePlaymode == false)
BuildMap();
}
#endif
[RuntimeInitializeOnLoadMethod]
private static void Init()
{
var index = Resources.Load<ResourcesIndex>(STORAGE_PATH);
assetsDict = new Dictionary<int, ResourceAsset>();
for (int i = 0; i < index.assets.Count; i++)
{
ResourceAsset asset = index.assets[i];
if (assetsDict.ContainsKey(asset.id))
{
Debug.LogErrorFormat("Duplicate asset ids = {0}", asset.id);
continue;
}
assetsDict.Add(asset.id, asset);
}
}
/// <summary>
/// Returns objects of types T
/// </summary>
/// <typeparam name="T"></typeparam>
/// <param name="id"></param>
/// <returns></returns>
public static T GetAsset<T>(int id) where T : Object
{
ResourceAsset asset;
if(assetsDict.TryGetValue(id, out asset))
return Resources.Load<T>(asset.assetPath);
return null;
}
[System.Serializable]
public struct ResourceAsset
{
public int id;
public string assetPath;
}
public List<ResourceAsset> assets;
}
This autobuilds the resources index taking out any manual setup, and all you have to do is call ResourcesIndex.GetAsset<CharacterSO>(characterIdHere);
You could expand this further to store the object type as well, if you needed to load all characters or something like that
Oh man, thank you for this incredibly generous illustration! Since you made it a ScriptableObject, I’m assuming your use case is giving a ResourcesIndex ref to anything in the scene that needs to locate data like then, and pointing them to the right SO in your basic Resources folder? Is there any reason you couldn’t set it up as a Mono instead, set DontDestroyOnLoad, and use it via the singleton or service locator pattern to act as kind of a roving database tool for anyone who needs to access non-mono data?
Also, this is just me being a tad slow, but is your intent with the IDs to give ever asset a unique numerical index, such that you’re calling all of your character SOs 01_bob, 02_jim, and so forth? Wouldn’t it make more sense to separate IDs by type, so you don’t end up having to do weird stuff like arbitrarily assigning ID ranges to keep from running into categorization issues where trying to add a hundredth character when id==100 is already assigned to a quest or other data asset means offsetting everything?
Well there’s no reason to assign references or anything like that, and no need for a singleton. The “singleton” is loaded in there Init method, which loads the only instance of the ScriptableObject needed from Resources. It’s loaded into memory as soon as your game starts, and will only be unloaded by calling Resources.UnloadUnusedAssets. The static dictionary will keep all the other references from being unloaded. You could make it into a MonoBehaviour if you wanted, but there is no reason to as it just adds unnecessary overhead.
Splitting it up by type would work as well, I was just making a universal example. You could also change it to not use integer id’s as the key, and instead just store the name of the asset as the id. Then you could just call
ResourcesIndex.GetAsset<CharacterSO>("character_jim");
// or
ResourcesIndex.GetAsset<QuestSO>("quest_tutorial");
But in my experience it’s always better to use numerical id’s.
That makes a lot of sense… thank you again for the detail of your approach, this made everything massively more streamlined than my original implementation. ^^