1
\$\begingroup\$

I want to have a serialized field with an array of game objects for each item in a global enum, like this:

public enum MyEnum
{
    Item1, 
    Item2, 
    Item3
}

And have a MonoBehaviour script present something like this in the inspector:

Array of game objects

I can't find proper documentation or example to make this work.

\$\endgroup\$
1
  • 1
    \$\begingroup\$ I show a quick & dirty method for doing this in this answer. I've been meaning to implement something like this in a generic way so you can just say EnumMappedArray<MyEnumType, MyContentType>, but getting it to handle all the edge cases of new enum values being added/changed/rearranged during development without data loss or messy manual fix-ups is non-trivial, without bloating the runtime access, so I've yet to make a complete implementation I'm 100% happy with. \$\endgroup\$
    – DMGregory
    Mar 16, 2023 at 15:30

1 Answer 1

2
\$\begingroup\$

Here's a generic version of the trick I showed in the comment above:

using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Assertions;

[System.Serializable]
public struct EnumMap<TKey, TValue> : IEnumerable<TValue> where TKey : System.Enum
{
    [Serializable]
    public struct Entry {
#if UNITY_EDITOR
        // This handles drawing the enum labels in the inspector,
        // without showing a human-editable field, or taking up memory in-build.
        [HideInInspector]
        public string name;
#endif
        public TValue value;
    }

    [SerializeField] Entry[] _map;

    // Handles error reporting if we somehow get bad enum values 
    // (eg. from disc/network or faulty math that's cast to enum).
    static void Validate(TKey key) {
        Assert.IsTrue(System.Enum.IsDefined(typeof(TKey), key),
            $"Invalid {typeof(TKey).Name} value {key} used as an EnumMap key.");
    }

    static int GetIndex(TKey key) {
        Validate(key);
        // Don't worry about this cast - the compiler does the right thing when
        // it reifies the generic into a concrete type, so there's no boxing.
        return (int)(object)key;
    }

    // Provide 
    public TValue this[TKey key] {
        get  {
            return _map[GetIndex(key)].value;
        }
        set {
            _map[GetIndex(key)].value = value;
        }
    }

    public void Initialize() {
        // Make a blank map from scratch.
        var names = System.Enum.GetNames(typeof(TKey));
        _map = new Entry[names.Length];
        for (int i = 0; i < names.Length; i++) {
            _map[i].name = names[i];
        }
    }

    /// <summary>
    /// Call this method in OnValidate() and Reset() when using this 
    /// collection in a MonoBehaviour or ScriptableObject. 
    /// This handles changes in the key enumeration type.
    /// </summary>
    public void TryRevise()
    {
        // Compiles to a no-op in player builds and should be elided.
#if UNITY_EDITOR        
        if (_map == null) {
            Initialize();
            return;
        }

        // Scan enum names for changes that force data to be remapped.
        var names = System.Enum.GetNames(typeof(TKey));
        // TODO: also validate that names are unique, and use consecutive indices starting at zero?
        if (names.Length != _map.Length) {
            // Update if the number of enum members changed.
            RemapData(names);
        } else {
            // Update if any of the names of enum members
            // changed or re-odered.
            for (int i = 0; i < names.Length; i++) {
                if (names[i] != _map[i].name) {
                    RemapData(names);
                    break;
                }
            }
        }
#endif       
    }

#if UNITY_EDITOR
    // Editor-only container for unmapped data - gives an opportunity to recover
    // after accidentally leaving an old name unmapped.
    // TODO: use PropertyDrawer to show/manually delete orphaned data.
    [SerializeField, HideInInspector] List<Entry> _orphaned;

    // Used to track re-mapping from old names to new.
    [System.Flags]
    enum SlotStatus {
        Unmapped = 0,
        OldEntryMatchedFromHere = 1 << 0,
        NewEntryMatchedHere = 1 << 1,
        NewEntryDefault = 1 << 2
    }

    // Called when the enum has changed, to move data to the best-matched slots.
    void RemapData(string[] names) {
        // Make a fresh array to handle any changes in size/order 
        // without unnecessary loss of data.
        var newMap = new Entry[names.Length];

        // Book-keeping to detect which old data has not been mapped.
        var slotStatus = new SlotStatus[Mathf.Max(newMap.Length, _map.Length)];
        int matches = 0;
        for (int i = 0; i < newMap.Length; i++) {
            Entry existingOrBlank;
            // Try to find existing data mapped to exactly the same enum value name.
            int matchIndex = Array.FindIndex(_map, item => item.name == names[i]);
            if (matchIndex >= 0) {
                // If we found it, insert it into the new map at its new index.
                existingOrBlank = _map[matchIndex];                
                slotStatus[matchIndex] |= SlotStatus.OldEntryMatchedFromHere;
                slotStatus[i] |= SlotStatus.NewEntryMatchedHere;
                matches++;
            } else {
                // Otherwise, check if we saved it in the orphanded list.
                matchIndex = _orphaned.FindIndex(item => item.name == names[i]);
                if (matchIndex >= 0) {
                    existingOrBlank = _orphaned[matchIndex];
                    _orphaned.RemoveAt(matchIndex);
                    slotStatus[i] |= SlotStatus.NewEntryMatchedHere;
                } else {
                    // Final fallback: make a new, blank entry for the new name.
                    existingOrBlank = default;
                    existingOrBlank.name = names[i];
                    slotStatus[i] |= SlotStatus.NewEntryDefault;
                }
            }
            newMap[i] = existingOrBlank;
        }

        if (matches < _map.Length) {
            // If some old data was not remapped to new, go find it!
            for (int i = 0; i < _map.Length; i++) {
                if ((slotStatus[i] & SlotStatus.OldEntryMatchedFromHere) == 0) {
                    // If no old data was mapped to the same integer slot as this unmapped data,
                    // assume it was just renamed in-place and copy its data to the new name.                  
                    if (i < newMap.Length && slotStatus[i] == SlotStatus.NewEntryDefault)  {
                        Debug.LogWarning($"Unmapped enum value {i}: {_map[i].name} found - assuming it should map to new name {names[i]}.");
                        newMap[i] = _map[i];
                        newMap[i].name = names[i];
                    } else {
                        // Otherwise, we don't know where this data goes. 
                        // Log an error that it's lost, for one of two reasons...
                        string problem = i < names.Length ? $"its slot has been mapped to {names[i]}" : "it's outside the range of the enum";
                        Debug.LogError($"Unmapped enum value {i}: {_map[i].name} found, but {problem}.");

                        // Cache unmaped data as an orphan.
                        string oldName = _map[i].name;
                        _orphaned.RemoveAll(item => item.name == oldName);
                        _orphaned.Add(_map[i]);
                    }
                }
            }
        }

        // Use the new map from now on.
        _map = newMap;
    }
#endif

    // Enabling use with ForEach:
    public struct Enumerator : IEnumerator<TValue> {
        Entry[] _map;
        int _index;

        public Enumerator(Entry[] map) {
            _map = map;
            _index = -1;
        }

        public bool MoveNext() {
            _index++;
            return _index < _map.Length;
        }

        public TValue Current => _map[_index].value;
        object IEnumerator.Current => Current;

        public void Reset() {
            _index = -1;
        }

        void IDisposable.Dispose() { }
    }

    public IEnumerator<TValue> GetEnumerator() => new Enumerator(_map);
    IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}

You can use it like so:

public class MyScript : MonoBehaviour {

    [System.Serializable]
    EnumMap<MyEnum, GameObject[]> _enumMap;

    void Start() {
        // You can index it with an enum value...
        foreach(var gameObject in _enumMap[MyEnum.Item1]) {
            // Do something with each game object 
            // in the "Item1" slot.
        }

        // Or iterate over all its contents:
        foreach(var array in _enumMap) {
            // Do something with the whole array of game objects
            // in each enum value slot.
        }
    }


    // These calls are needed to correctly set up the
    // array slots when the component is added / reset,
    // and update the data mapping after a change to the enum.
    void OnValidate() {
        _enumMap.TryRevise();
    }

    void Reset() {
        _enumMap.TryRevise();
    }
}

Pros with this approach:

  • Automatically adjusts to changes in the enum values when loaded in the inspector or during a build.
  • Enum values whose order has changed will remember their mapped payload value.
  • Enum values that are renamed in-place will get mapped to their new names (with a warning in case that was not intended).
  • Enum values that are removed will have their "orphaned" data saved and log an error. If you re-introduce that value name, the orphaned data will be restored into that slot.
  • No extra editor scripts to write - works with default inspector.
  • No class overhead (extra pointer indirection/heap allocation in build) or boxing. This compiles down to just a dense array of payload values indexed with an integer, about as lean as we could make it.

Cons with this approach:

  • Need to remember to implement the OnValidate()/Reset() messages whenever you use one of these containers.
  • Does not play nicely with enums that contain non-consecutive values, synonyms (multiple names mapped to the same value), or negative values.
  • Outer list still shows the +- controls so it can be resized. The OnValidate() script will immediately set it back to the correct size, but if a user mistakenly shrinks the list, data payloads attached to the later items will be lost, since OnValidate() only sees the version of the array after it was shrunk.
  • Has some overhead in editor, allocating enum name array on every change just to check if it's different now.
  • While making small enum changes one at a time is safe, if one of your assets hasn't been opened for a while, there's no guarantee it will be able to remap all your data correctly and you might have to go hunting for the orphaned data to clean up.

A custom property drawer could make this a bit nicer - like giving an interface to view and re-map orphaned data.


Here are some other ways we could implement this to get rid of the requirement for OnValidate calls wherever this is used - maybe directions others could explore in their own answers:

  • Make this a generic class instead and implement custom serialization via ISerializationCallbackReceiver to do what I'm doing above in OnValidate, without needing to add function calls to all scripts with such containers as members.

    • The downside with this is that now it's a reference type, so we've added some overhead: it gets a heap allocation separate from its contained array, so accessing an array entry is now two pointer hops instead of one. Its OnAfterDeserialize() also gets called in compiled builds, even though there can't be runtime changes to the enum to respond to, so this is unnecessary waste.

      Sadly, ISerializationCallbackReceiver does not work for value types like structs.

  • Make an [EnumMapped(typeof(TKey)] attribute you can use to decorate a plain array of your payload type, then implement a custom property drawer that handles sizing and labelling the array entries to match the names of the enum TKey.

    • This is nice and lightweight, but it doesn't give any of the convenience of being able to edit the enum and have the data automatically re-map to match the updated definition or warn about mismatched data, so any enum changes have the potential to create bugs that are hard to find and fix manually.

      It also requires anything accessing the array to do the enum validation and conversion to int itself, since this is just a plain old array of values now, rather than being able to provide an enum-native interface with the error-checking centralized.

  • Hybrid: define a generic struct like above, but use the property drawer to handle the data fix-ups on edit, instead of the serialization callbacks.

    • The downside with this is that the data fix-ups are invoked only when the field is drawn in the inspector. So if you have 200 prefabs or scriptable objects or game objects in different scenes using enum-mapped collections, you have to select every single one after an enum change to ensure their data has been re-serialized correctly. If you miss one, your next game build could have mismatched data, leading to nightmare bugs.

      Maybe there's a way to work around this with an editor script? But that might require scanning all assets for members using this type after any file change, which would bog down assembly reloads...

I feel like there has to be a more elegant solution that doesn't have the pain points of these, so I'd be curious what methods others can suggest in their own answers!

\$\endgroup\$
2
  • \$\begingroup\$ Thanks for your answer, I was trying to make my own code following your linked method. But did not managed to make the GetObjectsForItem function. The code is clear and well explained ! thanks a lot and well done. \$\endgroup\$
    – Felox
    Mar 16, 2023 at 20:13
  • \$\begingroup\$ I've updated this with a more full-featured generic version, that also handles enum values renamed in-place and printing warning/errors about possible remapping problems. Still not 100% ideal, but better than my previous versions I'd say. \$\endgroup\$
    – DMGregory
    Mar 16, 2023 at 20:21

You must log in to answer this question.

Not the answer you're looking for? Browse other questions tagged .