Writing a custom class:YieldInstruction to wait for frames or seconds depending on the class params

Hi everyone,

I’m writing a custom monobehaviour where I need to define in several fields some delays, and I want the level designer to have as much freedom as possible when playing with those parameters

in the real case scenario I have a monobehaviour that gets triggered for some reasons, then I have a customizable delay (frames, seconds, or a combinations of the two) before checking other conditions, and, if the conditions are met, I want to be able to specify again an extra delay after the condition checking for the final effect to take place.

I’ve discovered I should be subclassing from YieldInstruction to do that, but can’t find much info about this, nor examples.

So, here’s the code so far:

[System.Serializable]
public class WaitTimeEx : CustomYieldInstruction
{
    public enum WaitModes {
        NotZero,
        Cumulative,
        ConcurrentFirst,
        ConcurrentLast
    }
   
    public int frames;
    public float seconds;
    public WaitModes waitMode;
    
    protected float startTime;
    protected int framesPassed;
   
    public override bool keepWaiting
    {
        get {
            switch (waitMode)
            {
            case WaitModes.Cumulative:
                ???
                break;
            case WaitModes.ConcurrentFirst:
                framesPassed++;
                return (Time.time - startTime < seconds && framesPassed < frames);
                break;
            case WaitModes.ConcurrentLast:
                ???
                break;
            default:
                ???
                break;
            }
           
        }
    }
   
    public WaitTimeEx(int frames, float seconds, WaitModes waitMode = WaitModes.NotZero)
    {
        this.frames = frames;
        this.seconds = seconds;
        this.waitMode = waitMode;

        Reset();
    }

    protected void Reset()
    {
       startTime = Time.time;
       framesPassed = 0;
    }

    public Wait()
    {
        Reset();
    }
}


// Declaration in class:
public WaitModeEx delayBeforeConditionCheck;
public Condition condition;
public WaitModeEx delayAfterConditionCheck;

// Usage:

yield return delayBeforeConditionCheck();
if (condition.IsTrue())
{
       yield return delayAfterConditionCheck;
       <do some stuff>
}

I’ve never written a similar code, so I’m a bit lost. I’m not sure the WaitModeEx fields are going to show up in the inspector, or if I’m going to have to declare a similare WaitInfo class to save the 3 params to pass to the WaitTimeEx function.

Could someone please help me in filling the gaps? I know I should return true to delay the function and false to exit from it, but that’s all, I’m a bit unsure, yet I believe it’s possible to build such a class.

for reference:

https://blogs.unity3d.com/2015/12/01/custom-coroutines/
http://chriskugler.com/2016/01/25/unity-customyieldinstruction/

I’ll keep experimenting but I’m not sure that’s the proper way to do it.

I don’t understand your wait modes. What are their purpose, and what do they mean?

You’ve got the right idea, pass in the required data in the constructor, and check it in keepWaiting. I don’t think you should be checking either seconds or frames in the same yield instruction, though! Unless you’ve got a good reason, that sounds like two different yields.

I’m curious why the custom yield instruction at all. I’ve always found yield return null in a loop to be sufficient for virtually any type of behavior.

Then again, I grew up in a Unity with no custom yield instructions. They may have some advantage I’ve just never investigate.

Kind of amusing to me… I figured I’d look at the scripting forum for the first time in months (if not years) and I happen stumble across a post that’s both:

a) something that I write about many years ago
b) involving users ( @Kiwasi and @NeatWolf ) that I ‘know’

CustomYieldInstruction is a thin wrapper around writing a custom IEnumerator. I wrote a post about doing the same back in 2012: Extending IEnumerator for "custom" coroutines - Unity Engine - Unity Discussions

You will have to put up with the thread which was posted before I gave up on arguing with people about programming on forums (most of the thread is me arguing with people who didn’t know what they were talking about … and a few more productive arguments with people who did such as @stimarco ).

Anyways, thread includes a custom IEnumerator implementation example, not sure its worth even worrying about the CustomYieldInstruction wrapper, although I guess it saves a little code.

But as @Baste mentioned its kind of hard to offer specific advice as its unclear what the meaning of your enum values are! If you can provide details on each of them I’d be happy to chime in with a more detailed response.

1 Like

Oh, hi there @ :slight_smile:

My goal is to have a core reusable multi-purpose definitive function to be able to wait for a set number of frames, or a set number of seconds.

Think of the frame counting and time counting as two separate processes.

    public enum WaitModes {
        NotZero,
        Cumulative,
        ConcurrentFirst,
        ConcurrentLast
    }

NotZero will only use either the frames or the seconds value, and one of them should be left at zero. So this mimics a frame delay or a time delay.

Cumulative will add up the delays, so you can wait for 1 frame after 0.1 seconds (maybe I should consider the actual order of the delay that is going to be handled first, like FramesThenSeconds and SecondsThenFrames)

ConcurrentFirst starts both the “processes” and exits when the first one finishes.

ConcurrentLast starts both the processes and exits when both have finished.

I’m unsure if the NotZero makes sense, but should be the default behaviour. If one of the fields is 0, Cumulative should work exacly in the same way, and should also take both field values into account.

I’m making progresses, as I think I got the hang of how this works, but I’m not 100% sure it will be precise “at frame level”.
That is, I may end up waiting +/-1 frames according to the expectations, I should do some testing.

Also the sintax I was hoping to use doesn’t work. I had to call them like this, creating a new instance and passing the structure of the delay to the constructor, along with extra optional parameters to add extra frames or extra seconds.

I don’t think it’s possible to reuse an instantiated Custom yield routine that also gets serialized within the class, but please correct me if I’m wrong:

Usage:

    protected virtual void OnTrigger(Object obj)
    {
        if (triggerEnabled && triggerType == TriggerTypes.OnTrigger)
            StartCoroutine("FireTrigger", obj);
    }

    public virtual IEnumerator FireTrigger(Object obj)
    {
        yield return new WaitTimeEx(delayBeforeConditionCheck,
        (triggerType == TriggerTypes.OnStart || triggerType == TriggerTypes.OnAwake)? 1: 0);
   
        if (triggerCondition == null
            || !triggerCondition.IsTrue(obj as Transform))
            yield break;
   
        yield return new WaitTimeEx(delayBeforeTriggerEffects);
   
        DoTriggerBefore(obj);
        DoTrigger(obj);
        DoTriggerAfter(obj);
    }

Alternative constructors are provided as well, so you don’t have to create or initialize any classes in advance.
I was thinking about having the default constructor to require Frames, and only optionally seconds, so you could use yield return new WaitTimeEx(2) to wait for 2 frames, and probably using the existing WaitForSeconds for seconds only.

I may also rename the class as well to something more appropriate.

What happens if the yield keepWaiting function returns false on the first run? At least a frame gets skipped, or everything just flows like no delays happened (as I would expect)? I should do some testing.

To get a serializeable wait-for-seconds yield instruction, you have to be a bit clever. Since it’s constructed as you enter play, you can’t register the start time in it’s constructor. Here’s a work-around:

[Serializable]
public class WaitSeconds : CustomYieldInstruction {

    public float seconds;
    private float startTime;

    public WaitSeconds Wait() {
        startTime = Time.time;
        return this; //Now Wait() can be yielded
    }

    public override bool keepWaiting {
        get {
            return Time.time - startTime < seconds;
        }
    }
}

usage:

public WaitSeconds waitSettings;

IEnumerator Start() {
    yield return waitSettings.Wait();
}

WaitSeconds could also just be a normal class, and Wait could yield an inner class that extends CustomYieldInstruction - basically turning WaitSeconds into a factory. You’ll have to do that if it can be yielded while it’s still running.

If you need sequential yields, just yield one after another. If you need concurrent waits - ie. “wait for all of these, starting at the same time”, this will do the trick much easier:

public class WaitForAll : IEnumerator {
    private IEnumerator[] waitInstructions;
    private bool[] finished;

    public WaitForAll(params IEnumerator[] waitInstructions) {
        this.waitInstructions = waitInstructions;
        finished = new bool[waitInstructions.Length];
    }

    public bool MoveNext() {
        for (int i = 0; i < waitInstructions.Length; i++) {
            if (!finished[i] && !waitInstructions[i].MoveNext()) {
                finished[i] = true;
            }
        }

        for (int i = 0; i < waitInstructions.Length; i++) {
            if (!finished[i]) {
                return true;
            }
        }
        return false;
    }

    public object Current { get { return null; } }
    public void Reset() {}
}

//Usage example:

public class TestScript : MonoBehaviour {

    public bool cont;
    public WaitSeconds waiter;

    private IEnumerator Start() {
        yield return new WaitForAll(
            new WaitWhile(() => !cont),
            waiter.Wait()
        );
        Debug.Log("waiting done!.");
    }
}

If you put the above example in a scene, the debug message will show up when cont is true and the amount of time you set waiter to wait, no matter when those two things happen in relation to each other.

I think a design where you can freely have a concurrent set of yields is much better than to mix the idea of how you’re waiting (for seconds or frames) with the concurrency settings. Right now, you’re trying to mix two concepts - concurrency/sequential and seconds/frames - and it’s hard to write and hard to read.

I could advice you to get better names for WaitModes, but it’s much better to just remove that concept entirely.

The only problem with this design is that you can’t use the oldest built-in yield instructions, as they’re not inheriting from IEnumerator. So things like WaitForFixedUpdate is out of the question. You can manually implement those with some effort, though.

I have my own Interpolate Monobehaviour class that allows you to choose which Yield the internal coroutine uses at runtime, and can switch while the coroutine is still running. Available yields include:

  • Frame (yield return null)
  • Physics (WaitforFixedUpdate)
  • End Of Frame (WaitForEndofFrame)
  • Seconds (WaitForSeconds)
  • Unscaled Seconds (WaitForSecondsRealTime)
  • Service (AsyncOperation or ResourceRequest)
  • Custom (CustomYieldInstruction)

Service and Custom Instructions are wrapped inside a ScriptableObject class (YieldInstructionData) so that the user can choose which yield they want via the inspector. its loaded with several other features too, like Coroutine Pause/Resume/Abort, Optional Inputs, its own TimeScaling, and a selection of Easing methods

InterpelationGate.cs

You won’t be able to simply Copy/paste the code here without the WoofTools package (which I’m still writing up). But hopefully it should help you get your feet wet. plus I hope you find the code easy enough to follow.

using System.Collections;
using UnityEngine;
using WoofTools.API;
using WoofTools.Attributes;
using WoofTools.Events;
using WoofTools.Models;
using WoofTools.Utilities;

namespace WoofTools.MonoBehaviours
{
    public class InterpelationGate: MonoBehaviour
    {
        private static readonly YieldInstruction fixedUpdate      = new WaitForFixedUpdate();
        private static readonly YieldInstruction endofFrameUpdate = new WaitForEndOfFrame();

        private Coroutine handle;
        private float lastRealTime = 0;
        private WaitForSeconds waitforSeconds;

        [Tooltip("The Type of Interpelation to perform")]
        [SerializeField]private InterpelationType interpelationMode = InterpelationType.SmoothStep;
        [Tooltip("The value to interpelate from")]
        [SerializeField][Delayed]private float m_start = 0;
        [Tooltip("The value to interpelate to")]
        [SerializeField][Delayed]private float m_end = 1;
        [Tooltip("how long the interpelation takes to complete")]
        [SerializeField][Delayed]private float m_duration = 1;

        [Tooltip("You can specify how the interpelation will update")]
        [SerializeField]private UpdateInterval updateMode = UpdateInterval.Frame;
        [Tooltip("You can optionally use a unique timescale, or leave the field empty to use Unity's Time.timeScale")]
        [SerializeField]private TimeScale m_timeScale;

        [Tooltip("You can optionally provide an Input that will start/restart the interpelation")]
        public NamedInputData StartInput = null;
        [Tooltip("You can optionally provide an Input that will pause the interpelation")]
        public NamedInputData pauseInput = null;
        [Tooltip("You can optionally provide an Input that will abort the interpelation")]
        public NamedInputData abortInput = null;

      
        // TODO: set up editor scripting for these to show only under the right interval mode
        [Tooltip("Provide a 'Tick' interval in between updates (used only if Update Mode is Seconds Scaled or Seconds UnScaled)")]
        [SerializeField][Delayed]private float m_secondsInterval = 1;
        [Tooltip("Provide a custom YieldInstruction (used only if Update Mode is service)")]
        public YieldInstructionData Service = null;
        [Tooltip("Provide a custom YieldInstruction (used only if Update Mode is custom)")]
        public YieldInstructionData customYield = null;

        [ReadOnly][SerializeField]private float current;
        [ReadOnly][SerializeField]private bool  m_paused = false;

        public FloatEvent OnInterpelationBegin   = new FloatEvent();
        public FloatEvent OnInterpelationUpdate  = new FloatEvent();
        public FloatEvent OnInterpelationFinish  = new FloatEvent();
        public BoolEvent  OnInterpelationPause   = new BoolEvent();
        public FloatEvent OnInterpelationAborted = new FloatEvent();

        public InterpelationType InterpelationMode {get{return interpelationMode;} set{interpelationMode = value; }}
        public float Start                         {get{return m_start;}           set{m_start = value;           }}
        public float End                           {get{return m_end;}             set{m_end = value;             }}
        public float Duration                      {get{return m_duration;}        set{m_duration = value;        }}
        public UpdateInterval IntervalMode         {get{return updateMode;}        set{updateMode = value;        }}
        public TimeScale TimeScale                 {get{return m_timeScale;}       set{m_timeScale = value;       }}

        public float SecondsInterval               
        {
            get{return m_secondsInterval;}     
            set
            {
                m_secondsInterval      = value;
                waitforSeconds         = new WaitForSeconds(m_secondsInterval);
            }
        }

        public float Current
        {
            get{return current;} 
            set
            {
                this.TryStopCoroutine(ref handle);
                current = value;
                OnInterpelationUpdate.Invoke(current);
            }
        }

        private float DeltaTime
        {
            get
            {
                float realTimeDelta = Time.realtimeSinceStartup - lastRealTime;
                lastRealTime = Time.realtimeSinceStartup;

                float deltaTime = (TimeScale != null) ? realTimeDelta * TimeScale.scale:realTimeDelta;
                float fixedDeltaTime = (TimeScale != null) ? Time.fixedDeltaTime * TimeScale.physicsScale:Time.fixedDeltaTime;

              
                if(IntervalMode == UpdateInterval.Frame)           return deltaTime;
                if(IntervalMode == UpdateInterval.PhyicsFrame)     return fixedDeltaTime;
                if(IntervalMode == UpdateInterval.EndOfFrame)      return realTimeDelta;

                if(IntervalMode == UpdateInterval.SecondsScaled)   return deltaTime;
                if(IntervalMode == UpdateInterval.SecondsUnscaled) return Time.unscaledDeltaTime;
                if(IntervalMode == UpdateInterval.Service)          return realTimeDelta;
                if(IntervalMode == UpdateInterval.Custom)          return realTimeDelta;
              
                // default to deltaTime, the most common version
                return deltaTime;
            }
        }

        private System.Object currentYield
        {
            get
            {
                if(m_paused) return null;

                if(IntervalMode == UpdateInterval.Frame)                        return null;
                if(IntervalMode == UpdateInterval.PhyicsFrame)                  return fixedUpdate;
                if(IntervalMode == UpdateInterval.EndOfFrame)                   return endofFrameUpdate;


                if(IntervalMode == UpdateInterval.SecondsScaled)                return waitforSeconds;
                if(IntervalMode == UpdateInterval.SecondsUnscaled)              return new WaitForSecondsRealtime(m_secondsInterval);
                if(IntervalMode == UpdateInterval.Service && Service!= null)    return Service.Yield;
                if(IntervalMode == UpdateInterval.Custom && customYield!= null) return customYield.Yield;

                return null;
            }
        }

        private void OnValidate()
        {
            if(!Application.isPlaying) return;

            waitforSeconds       = new WaitForSeconds(m_secondsInterval);
        }

        private void OnEnable()
        {
            waitforSeconds       = new WaitForSeconds(m_secondsInterval);
            current = Start;
        }

        private void Update()
        {
            //if Input is null the extension method TryDown() will return false
            if(StartInput.TryDown()) Interpelate();

            if(abortInput.TryDown()) Stop();

            if(pauseInput.TryDown()) Pause();
        }

        public void Interpelate()
        {
            m_paused = false;
            this.RestartCoroutine(InterpelateThread(), ref handle);
        }

        public void Pause()
        {
            if(handle ==null) return;

            m_paused = !m_paused;
            OnInterpelationPause.Invoke(m_paused);
        }

        public void Stop()
        {
            if(handle ==null) return;

            StopCoroutine(handle);
            float abortedPercent = current;
            m_paused = false;
            OnInterpelationAborted.Invoke(abortedPercent);
            current = Start;
        }
      
        private IEnumerator InterpelateThread()
        {
            current = Start;
            float lerp = 0;
            lastRealTime = Time.realtimeSinceStartup;
            OnInterpelationBegin.Invoke(Start);
          
            while (lerp<1&& Duration>0)
            {
                if(!m_paused)
                {
                    lerp = Mathf.MoveTowards(lerp,1,DeltaTime / Duration);
                    current = Interpelation.Mode[InterpelationMode].Invoke(Start,End,lerp);
                    OnInterpelationUpdate.Invoke(current);
                }

                yield return currentYield;
            }
          
            this.TryStopCoroutine(ref handle);
            current = Start;
            m_paused = false;
            OnInterpelationFinish.Invoke(End);
        }
      
      
        public enum UpdateInterval
        {
            Frame,       // the most common yield type
            PhyicsFrame, // yielding for physics calculation
            EndOfFrame   // yielding for endof frame (mostly for render-dependent stuff like cameras)

            ,SecondsScaled, SecondsUnscaled // specific yields giving a "tick" update
            ,Service   //encompasses YieldInstuctions like  AsyncOperation and ResourceRequest

            ,Custom    //encompasses all CustomYieldInstructions like WaitWhile and WaitForSecondsRealtime
        }
    }
}

futhermore, I have another version of the class as one of my Command Classes (which derives from scriptableObject). This way a Monobehaviour can have a list of commands and run them (sequentially or parallel) as a coroutine.