6
\$\begingroup\$

[EDIT: based on some feedback, I pruned the example down to the base functionality of the bug we are trying to solve. The description below remains accurate, but the repro has been shortened to:

  1. Add the FXTriggerTimer component to an object
  2. Hit the blue '+' button to add an AnimEvent and a SFXEvent
  3. Save the scene, leave the scene and come back to the scene
  4. Notice that the object has blank FXEvents instead of AnimEvents & SFXEvents
  5. Hit space in a script file and save to trigger a script refresh in Unity
  6. Notice that the object has had AnimEvents & SFXEvents restored

FINALLY: if anybody knows how to solve this problem without using scriptable or serializeable objects, that would be preferred. Thanks a million!]

Preamble

I'm trying to hook up a way to synchronize animations & sfx (and potentially later particle fx, camera movement, etc) calls for our artists to use when placing art in our levels. The way I have come up with is to have a trigger that has an array of base class type FXEvent, with child classes of type AnimEvent and SFXEvent. C# in general handles polymorphism pretty well, but Unity's Inspector, alas, does not. and I fear that is the root of my problem.

I solved this problem 4 years ago, so I know it can be done; but, I do not have access to that code anymore. I'm trying to build a solution this time with ScriptableObject and SerilizableObject, and I have something that almost works.

My problem is as follows:

I apply my FXTriggerTimer MonoBehavior (which inherits from FXTriggerBase) to a test object, and then hit the blue '+' button to add an AnimEvent and a SFXEvent to the trigger. I then save the scene, and reload the scene, and my test object has an array of two null elements that (obviously) don't draw in the inspector. BUT, if I edit a script, any script (e.g. hit space and save), the script refresh will magically repopulate with the correct items. So I know that all of the pieces are there under the hood, they just need to be restored on scene load.

NOTE: I am not wedded to my current solution with ScriptableObject and SerializableObject. It feels a bit over complicated to me, but it has brought me closest to a real solution.

Code below.

Also, there's an example scene downloadable here, if anybody wants to look at the problem that way.

The base polymorphic stuff that I'm trying to get to work:

//Scripts/FXTriggerBase.cs
using UnityEngine;
using System.Collections;

public enum FXEventType {kNone, kAnim,  kSFX,}

/*-----------------------------------------------------------------------------
*///---------------------------------------------------------------------------
[System.Serializable]
public class FXEvent: UnityEngine.ScriptableObject
{
    [SerializeField]        public FXEventType  type;       
    [SerializeField]        public GameObject   go;    
    virtual public void Fire()  {   }
};

/*-----------------------------------------------------------------------------
*///---------------------------------------------------------------------------
[System.Serializable]
public class AnimEvent : FXEvent
{       
    [SerializeField]    public string       animName = "";
    public AnimEvent()      {type = FXEventType.kAnim;}    
    public void Fire()      {go.animation.Play(animName);   }
}

/*-----------------------------------------------------------------------------
*///---------------------------------------------------------------------------
[System.Serializable]
public class SFXEvent: FXEvent
{
    [SerializeField]    public AudioClip    clip;
    public SFXEvent()       {type = FXEventType.kSFX;}      
    public void Fire()      {go.audio.PlayOneShot(clip);    }
}


/*-----------------------------------------------------------------------------
*///---------------------------------------------------------------------------
[AddComponentMenu("")]  // prevents users from using this directly
public class FXTriggerBase : MonoBehaviour 
{
    [SerializeField]    public FXEvent[] events = new FXEvent[]{};

    public void Fire()
    {
        for( int e = 0; e < events.Length; e++)
        {
            events[e].go = gameObject;
            events[e].Fire();
        }   
    }
}

A sample MonoBehavior that uses it:

//Scripts/FXTriggerTimer.cs
using UnityEngine;
using System.Collections;

/*-----------------------------------------------------------------------------
*///---------------------------------------------------------------------------
public class FXTriggerTimer: FXTriggerBase
{    
    public void Start()
    {
        Fire();
    }
}

A custom inspector utility class to edit it with - the bug is in here:

//Editor/FXTriggerBaseUtil.cs
using UnityEngine;
using System.Collections;
using UnityEditor;

/*-----------------------------------------------------------------------------
*///---------------------------------------------------------------------------
public class FXEditorUtil
{
    SerializedObject[] serial;  
    public bool dirtied = false;

    /*-------------------------------------------------------------------------
    *///-----------------------------------------------------------------------
    public void OnSubGUI(this FXEvent fx)
    {
        //having trouble getting polymorphism to work right in the inspector,
        // so this is what works.
        if(fx.type == FXEventType.kAnim)
        {
            AnimEvent anim = (AnimEvent)fx;
            string newAnimName = GUILayout.TextField(anim.animName);
            if(newAnimName != anim.animName)
            {
                anim.animName = newAnimName;
                dirtied = true;
            }
        }
        else if(fx.type == FXEventType.kSFX)
        {
            SFXEvent sfx = (SFXEvent)fx;
            AudioClip newSFX = (AudioClip)EditorGUILayout.ObjectField(sfx.clip, typeof(AudioClip));
            if(newSFX != sfx.clip)
            {
                sfx.clip =newSFX;
                dirtied = true;
            }
        }       
    }

    /*-------------------------------------------------------------------------
    *///----------------------------------------------------------------------- 
    public FXEvent OnGUI(FXEvent fx)
    {
        if(fx == null)
            return fx;

        GUILayout.BeginHorizontal();
        FXEvent ret = null;

        //display the enum type so we can see what we're editing
        GUILayout.Label(fx.type.ToString(), GUILayout.Width(60));       

        //handle element GUI, if that applies
        if(fx.type != FXEventType.kNone)
            OnSubGUI(fx);
        else
            GUILayout.Label("");

        GUILayout.EndHorizontal();
        return ret;
    }

    /*-------------------------------------------------------------------------
    *///-----------------------------------------------------------------------
    public void OnGUI(FXTriggerBase trigger)
    {
        FXTriggerBase triggerBase = trigger as FXTriggerBase;
        if(triggerBase == null)
        {
            Debug.LogError("FXTriggerBaseInspector.OnGUI() : null item to inspect?");
            return;
        }

        //build an array of serialized versions of our array elements, so that they
        // save out properly
        int numEvents = triggerBase.events.Length;
        serial = new SerializedObject[numEvents];
        for(int e = 0; e < numEvents; e++)
        {
            serial[e] = new SerializedObject(triggerBase.events[e]);
        }

        dirtied = false;

        //Do inspector GUI of our array elements
        int ret = ArrayGUI("events", trigger.events, trigger.gameObject);

        //handle addition of elements
        if (ret >= 0)
            ArrayHandleAdd(ref trigger.events);

        //if any changes have been registered, save our serialized versions out
        if(dirtied)
        {
            numEvents = serial.Length;
            for(int e = 0; e < numEvents; e++)
            {
                serial[e].ApplyModifiedProperties();
            }

            EditorUtility.SetDirty(trigger);
        }
    }

    /*-------------------------------------------------------------------------
    *///-----------------------------------------------------------------------
    int ArrayGUI(string label, FXEvent[] arr, GameObject go)
    {
        int ret = -1;
        Color defColor = GUI.color;

        GUILayout.BeginVertical(GUILayout.Width(250));  

        //blue plus button that populates some events for us
        GUILayout.Label("");
        GUI.color = Color.cyan;
        bool add = GUILayout.Button("+", GUILayout.Width(20));
        if (add)
            ret = arr.Length;
        GUI.color = defColor;

        GUILayout.Label(label);

        int numObjects = arr.Length;
        for (int o = 0; o < numObjects; o++)
        {
            GUILayout.BeginHorizontal();
            {
                //draw custom GUI for each of the polymorphic fx event types
                FXEvent fx = arr[o];
                FXEvent newFX = OnGUI(fx);
                if(newFX != null)
                {
                    arr[o] = newFX;
                    dirtied = true;

                    serial[o] = new SerializedObject(arr[o]);
                    ScriptableObject.DestroyImmediate(fx);                      
                }
            }
            GUILayout.EndHorizontal();
        }               
        GUILayout.EndVertical();
        return ret;
    }

    /*-------------------------------------------------------------------------
    *///-----------------------------------------------------------------------
    void ArrayHandleAdd(ref FXEvent[]  arr)
    {
        int size = arr.Length;

        //add new element to the end
        System.Array.Resize(ref arr, size + 2);
        System.Array.Resize(ref serial, size + 2);

        arr[size] = ScriptableObject.CreateInstance<AnimEvent>();
        serial[size] = new SerializedObject(arr[size]);

        arr[size+1] = ScriptableObject.CreateInstance<SFXEvent>();
        serial[size+1] = new SerializedObject(arr[size+1]);

        dirtied = true;
    }
}

and sample custom inspector that uses that

//Editor/FXTriggerTimerInspector.cs
using UnityEngine;
using System.Collections;
using UnityEditor;

/*-----------------------------------------------------------------------------
*///---------------------------------------------------------------------------
[CustomEditor(typeof(FXTriggerTimer))]
public class FXTriggerTimerInspector : Editor
{
    FXEditorUtil util;

    public override void OnInspectorGUI()
    {
        FXTriggerTimer trigger = (FXTriggerTimer)target;
        if(util == null)
        {
            util = new FXEditorUtil();
        }

        util.OnGUI(trigger);

        if(util.dirtied)
        {
            EditorUtility.SetDirty(target);
            //serializedObject.ApplyModifiedProperties();
        }
    }
}
\$\endgroup\$
  • \$\begingroup\$ I'll have a look at the code when I have a few minutes (if you can reduce the amount of code would be better). Btw, I had the exact same problem in the past: I guess you are trying to save ScriptableObject instances inside scenes, that it something possible but not documented and kind of hard to do properly. \$\endgroup\$ – Heisenbug Feb 10 '15 at 19:06
  • \$\begingroup\$ This is about the minimum amount of code to reproduce the problem in its entirety - the actual real-world case is much hairier! Although, I could remove the array manipulation stuff - I originally left that to make it easier for people to repro if they decided to copy it into a unity project. I can simplify the MonoBehavior use case, so I'll do that. Btw, if you know how to serialize polymorphic objects from custom inspector without scriptable objects, I'm all ears! \$\endgroup\$ – Mike Gonzales Feb 10 '15 at 19:14
  • \$\begingroup\$ The core of your problem is to create a SerializableObject instance which is correctly saved in the scene, so you could eventually concentrate on a couple of lines of code. For what concern polymorphic object, no you can't have them serialized if not derived from monobehaviour or scriptableobject.I'll promise I'll investigate (ping me in a few days if you won't get an answer). \$\endgroup\$ – Heisenbug Feb 10 '15 at 20:04
  • \$\begingroup\$ It's the intersection of custom inspectors and polymorphic objects held in user editable arrays that make this problem vexing. I wish to leave those aspects in the problem description and not just focus on the serializable / scriptable object part out of some (probably vain) hope someone will tell me that serializably scriptable objects are over complicated for what I need and provide me with the magic solution that doesn't require them :D. \$\endgroup\$ – Mike Gonzales Feb 10 '15 at 20:52
  • \$\begingroup\$ @MikeGonzales Did you come up with a solution? \$\endgroup\$ – aggsol Mar 27 '15 at 13:05
1
\$\begingroup\$

Ok, as I said in my comment above, I have come to believe my problem is a genuine bug in Unity 4.x. [EDIT] It is still a bug in Unity 5.x. I couldn't wait anymore and I just hacked a non-polymorphic solution. I'm going to not green-check this answer in case some hero out there magically comes up with the right solution, but for the intrepid internet explorer who comes to this question with the exact same problem and needs a solution, stat, here's what I did.

I essentially ripped out my polymorphic solution and replaced it with a c-style union solution ... except C# doesn't really support unions. So it's a weird hack bloated union solution. I made the base class FXEvent into a container class for all of the specific types of events, and then used the enum type to determine which sub class to choose.

Besides the initial bloat of all the type-specific code that had to be written since polymorphism no longer hid the details, all of the event classes got further away from the mono behaviour that hosts them, so I had to write more stuff to make sure that GameObjects got passed all the way down to the leaf-events.

That said, in my real world case, this may require more maintenance than I'd like, but I actually think this solution is more kind on Unity3D; I think it ultimately runs better and is simpler under the hood and thus much less likely to go wrong in some bizarre way.

In the future, I might try to address the scalability problems using Generics or something like that; but for now this is how it stands.

//scripts.FXTriggerBase.cs
using UnityEngine;
using System.Collections;

public enum FXEventType {kNone, kAnim,  kSFX,}

/*-----------------------------------------------------------------------------
*///---------------------------------------------------------------------------
[System.Serializable]
public interface FXEventBase
{
    public virtual void  Awake(GameObject gobj);
    public virtual void  Fire();
}

/*-----------------------------------------------------------------------------
*///---------------------------------------------------------------------------
[System.Serializable]
public class AnimEvent : FXEventBase
{       
    [System.NonSerialized]
    public GameObject go;

    [SerializeField]
    public string       animName = "";
    public AnimEvent() {}

    public void Awake(GameObject gobj)  {go = gobj;}
    public void Fire()                  {go.animation.Play(animName);   }
}

/*-----------------------------------------------------------------------------
*///---------------------------------------------------------------------------
[System.Serializable]
public class SFXEvent: FXEventBase
{
    [System.NonSerialized]
    public GameObject go;

    [SerializeField]
    public AudioClip    clip;
    public SFXEvent()   {}

    public void Awake(GameObject gobj)  {go = gobj;}
    public void Fire()                  {go.audio.PlayOneShot(clip);    }
}

/*-----------------------------------------------------------------------------
*///---------------------------------------------------------------------------
[System.Serializable]
public class FXEvent
{
    [SerializeField]
    public FXEventType  type = FXEventType.kNone;

    [SerializeField]
    public GameObject   go;

    public void Awake(GameObject gobj)
    {
        if(go == null)
            go = gobj;

        if(type == FXEventType.kAnim)
            anim.Awake(go);
        else if ( type == FXEventType.kSFX)
            sfx.Awake(go);
    }

    public void Fire()  
    {   
        if(type == FXEventType.kAnim)
            anim.Fire();
        else if ( type == FXEventType.kSFX)
            sfx.Fire();
    }

    [SerializeField] public AnimEvent anim;
    [SerializeField] public SFXEvent sfx;

#if NULL
    //other fx events implemented in the real world case, to show how poorly
    // this solution scales
    [SerializeField]  public PFXEvent pfx;
    [SerializeField]  public CamEvent cam;
#endif

    //---------------------------------------------------------------------------

    static public FXEvent CreateAnimEvent(GameObject gobj)
    {
        FXEvent fx = new FXEvent();
        fx.go = gobj;

        fx.anim = new AnimEvent();
        fx.anim.go = gobj;
        fx.type = FXEventType.kAnim;
        return fx;
    }

    static public  FXEvent CreateSFXEvent(GameObject gobj)
    {
        FXEvent fx = new FXEvent();
        fx.go = gobj;

        fx.sfx = new SFXEvent();
        fx.sfx.go = gobj;
        fx.type = FXEventType.kSFX;
        return fx;
    }

    static public  FXEvent CreateNullEvent(GameObject gobj)
    {       
        FXEvent fx = new FXEvent();
        fx.go = gobj;

        return fx;
    }
};

/*-----------------------------------------------------------------------------
*///---------------------------------------------------------------------------
[AddComponentMenu("")]  // prevents users from using this directly
public class FXTriggerBase : MonoBehaviour 
{
    [SerializeField]
    public FXEvent[] events = new FXEvent[]{};

    //if the GameObject parent in the FXEvent wasn't assigned prior, do it now
    void Awake()
    {
        for(int i = 0; i < events.Length; i++)
        {
            events[i].Awake(gameObject);
        }
    }

    public void Fire()
    {
        for( int e = 0; e < events.Length; e++)
        {
            events[e].Fire();
        }   
    }
}

And for those keeping track, this is how my test inspector change to accommodate the changes; in contrast, minus all of the scriptable and serializeable object stuff, it is much simpler, though it will have the same scalability problems to come.

//editor/FXTriggerBaseInspector.cs
using UnityEngine;
using System.Collections;
using UnityEditor;


/*-----------------------------------------------------------------------------
*///---------------------------------------------------------------------------
public class FXEditorUtil
{
    public bool dirtied = false;

    /*-------------------------------------------------------------------------
    *///-----------------------------------------------------------------------
    public void OnSubGUI(this FXEvent fx)
    {
        //having trouble getting polymorphism to work right in the inspector,
        // so this is what works.
        if(fx.type == FXEventType.kAnim)
        {
            AnimEvent anim = fx.anim;
            string newAnimName = GUILayout.TextField(anim.animName);
            if(newAnimName != anim.animName)
            {
                anim.animName = newAnimName;
                dirtied = true;
            }
        }
        else if(fx.type == FXEventType.kSFX)
        {
            SFXEvent sfx = fx.sfx;
            AudioClip newSFX = (AudioClip)EditorGUILayout.ObjectField(sfx.clip, typeof(AudioClip));
            if(newSFX != sfx.clip)
            {
                sfx.clip =newSFX;
                dirtied = true;
            }
        }       
    }

    /*-------------------------------------------------------------------------
    *///----------------------------------------------------------------------- 
    public FXEvent OnGUI(FXEvent fx)
    {
        if(fx == null)
            return fx;

        GUILayout.BeginHorizontal();
        FXEvent ret = null;

        //if the enum changes, create new FXEvent sub type corresponding to the
        // new enum, and return it
        GUILayout.Label(fx.type.ToString(), GUILayout.Width(60));       

        //handle element GUI, if that applies
        if(fx.type != FXEventType.kNone)
        {
            OnSubGUI(fx);
        }
        else
            GUILayout.Label("");

        GUILayout.EndHorizontal();
        return ret;
    }

    /*-------------------------------------------------------------------------
    *///-----------------------------------------------------------------------
    public void OnGUI(FXTriggerBase trigger)
    {
        FXTriggerBase triggerBase = trigger as FXTriggerBase;
        if(triggerBase == null)
        {
            Debug.LogError("FXTriggerBaseInspector.OnGUI() : null item to inspect?");
            return;
        }

        dirtied = false;

        //Do inspector GUI of our array elements
        int ret = ArrayGUI("events", trigger.events, trigger.gameObject);

        //handle addition of elements
        if (ret >= 0)
            ArrayHandleAdd(ref trigger.events, trigger.gameObject);
    }

    /*-------------------------------------------------------------------------
    *///-----------------------------------------------------------------------
    int ArrayGUI(string label, FXEvent[] arr, GameObject go)
    {
        int ret = -1;
        Color defColor = GUI.color;

        GUILayout.BeginVertical(GUILayout.Width(250));  

        //blue plus button that populates some events for us
        GUILayout.Label("");
        GUI.color = Color.cyan;
        bool add = GUILayout.Button("+", GUILayout.Width(20));
        if (add)
            ret = arr.Length;
        GUI.color = defColor;

        GUILayout.Label(label);

        int numObjects = arr.Length;
        for (int o = 0; o < numObjects; o++)
        {
            GUILayout.BeginHorizontal();
            {
                //draw custom GUI for each of the polymorphic fx event types
                FXEvent fx = arr[o];
                FXEvent newFX = OnGUI(fx);
                if(newFX != null)
                {
                    arr[o] = newFX;
                    dirtied = true;                 
                }
            }
            GUILayout.EndHorizontal();
        }               
        GUILayout.EndVertical();
        return ret;
    }

    /*-------------------------------------------------------------------------
    *///-----------------------------------------------------------------------
    void ArrayHandleAdd(ref FXEvent[]  arr, GameObject go)
    {
        int size = arr.Length;

        //add new element to the end
        System.Array.Resize(ref arr, size + 2);

        arr[size] = FXEvent.CreateAnimEvent(go);                
        arr[size+1] = FXEvent.CreateSFXEvent(go);

        dirtied = true;
    }
}
\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.