Coroutines - Run operations over multiple ticks

From Space Engineers Wiki
Jump to navigation Jump to search

Introduction

One recurring problem with the programmable block is the fact that it runs in a time slot in the game’s main update loop. This means that performance is paramount and you really can’t do more complex operations before hitting the “too complex” exception. Even if you’re close to that limit you’re doing too much work, you should always strive to run your script as fast as you possibly can. A solution to that is to run complex operations over multiple ticks. Ordinarily this involves writing a state machine with a lot of state managing, switches and jumps, and quickly becomes difficult to maintain - and buggy. Fortunately for us, C# provides a solution for us.

The yielding enumerator enables some really powerful coroutine programming, by utilizing the state machine generated by the compiler. Before you continue, you should read about the keyword so you understand what it’s true purpose is. We’re gonna exploit it in a somewhat unintended way!

To begin; place a programmable block, an interior light (simply named Interior Light) and an LCD Panel set up to display its public text.

When do I yield?

One of the more common question I’ve been getting since posting this little snippet is: When should I yield? How much can I do before waiting for the next tick?

Unfortunately this is pretty much an unanswerable question, like asking “how long is a string?”. This ties into the quite logical fact that the fastest running code is the code that never runs. So all the advice I can give is this: If you can wait with an operation, then wait with that operation.

The Code

Copy the following script into the programmable block. The comments explain what is happening. Note that for the sake of simplicity, this example can only run one state machine at any one given time. It’s up to you to extend this to support executing multiple state machines if you need to, once you understand how this works.

Remember to dispose your IEnumerator after use or it will come back to haunt you!

IMyInteriorLight _panelLight;
IMyTextPanel _textPanel;
IEnumerator<bool> _stateMachine;

public Program() 
{
    // Retrieve the blocks we're going to use.
    _panelLight = GridTerminalSystem.GetBlockWithName("Interior Light") as IMyInteriorLight;
    _textPanel = GridTerminalSystem.GetBlockWithName("LCD Panel") as IMyTextPanel;

    // Initialize our state machine
    _stateMachine = RunStuffOverTime();

    // Signal the programmable block to run again in the next tick. Be careful on how much you
    // do within a single tick, you can easily bog down your game. The more ticks you do your
    // operation over, the better.
    //
    // What is actually happening here is that we are _adding_ the Once flag to the frequencies.
    // By doing this we can have multiple frequencies going at any time.
    Runtime.UpdateFrequency |= UpdateFrequency.Once;
}

public void Main(string argument, UpdateType updateType) 
{
    // Usually I verify that the argument is empty or a predefined value before running the state
    // machine. This way we can use arguments to control the script without disturbing the
    // state machine and its timing. For the purpose of this example however, I will omit this.

    // We only want to run the state machine(s) when the update type includes the
    // "Once" flag, to avoid running it more often than it should. It shouldn't run
    // on any other trigger. This way we can combine state machine running with
    // other kinds of execution, like tool bar commands, sensors or what have you.
    if ((updateType & UpdateType.Once) == UpdateType.Once)
    {
        RunStateMachine();
    }
}

// ***MARKER: Coroutine Execution
public void RunStateMachine()
{
    // If there is an active state machine, run its next instruction set.
    if (_stateMachine != null) 
    {
        // The MoveNext method is the most important part of this system. When you call
        // MoveNext, your method is invoked until it hits a `yield return` statement.
        // Once that happens, your method is halted and flow control returns _here_.
        // At this point, MoveNext will return `true` since there's more code in your
        // method to execute. Once your method reaches its end and there are no more
        // yields, MoveNext will return false to signal that the method has completed.
        // The actual return value of your yields are unimportant to the actual state
        // machine.
        bool hasMoreSteps = _stateMachine.MoveNext();

        // If there are no more instructions, we stop and release the state machine.
        if (hasMoreSteps)
        {
            // The state machine still has more work to do, so signal another run again, 
            // just like at the beginning.
            Runtime.UpdateFrequency |= UpdateFrequency.Once;
        } 
        else 
        {
            _stateMachine.Dispose();

            // In our case we just want to run this once, so we set the state machine
            // variable to null. But if we wanted to continously run the same method, we
            // could as well do
            // _stateMachine = RunStuffOverTime();
            // instead.
            _stateMachine = null;
        }
    }
}

// ***MARKER: Coroutine Example
// The return value (bool in this case) is not important for this example. It is not
// actually in use.
public IEnumerator<bool> RunStuffOverTime() 
{
    // For the very first instruction set, we will just switch on the light.
    _panelLight.Enabled = true;

    // Then we will tell the script to stop execution here and let the game do it's
    // thing. The time until the code continues on the next line after this yield return
    // depends  on your State Machine Execution and the timer setup.
    // The `true` portion is there simply because an enumerator needs to return a value
    // per item, in our case the value simply has no meaning at all. You _could_ utilize
    // it for a more advanced scheduler if you want, but that is beyond the scope of this
    // tutorial.
    yield return true;

    int i = 0;
    // The following would seemingly be an illegal operation, because the script would
    // keep running until the instruction count overflows. However, using yield return,
    // you can get around this limitation - without breaking the rules and while remaining
    // performance friendly.
    while (true) 
    {
        _textPanel.WriteText(i.ToString());
        i++;
        // Like before, when this statement is executed, control is returned to the game.
        // This way you can have a continuously polling script with complete state
        // management, with very little effort.
        yield return true;
    }
}

Other examples

SimpleTimerSM + example (by Digi)

Can be pasted as-is into a PB to see how it behaves, then play around with it!

Feel free to copy the SimpleTimerSM class to your own scripts aswell.

SimpleTimerSM class + example code
SimpleTimerSM TimerSM;

public Program()
{
    // create a handler and tell it to use Sequence() method as the coroutine
    TimerSM = new SimpleTimerSM(this, Sequence());

    // can also set TimerSM.Sequence at any time in the future including inside a coroutine,
    //   but will only use it when TimerSM.Start() is called.

    Echo("Run with 'start' argument");
}

public void Main(string argument, UpdateType updateSource)
{
    TimerSM.Run(); // runs the next step in sequence if started, this can always stay here.

    if(argument == "start")
    {
        BeginSequence();
    }
    else if(argument == "stop")
    {
        StopSequence();
    }
}

void BeginSequence()
{
    TimerSM.Start();

    // required because TimerSM.Run() needs to be called by Main() for it to run the sequence.
    Runtime.UpdateFrequency = UpdateFrequency.Update10;
    // can also set it to Update100 if the times between waits are long enough or you don't need precision.
}

void StopSequence()
{
    TimerSM.Stop();
    Runtime.UpdateFrequency = UpdateFrequency.None;
}

IEnumerable<double> Sequence()
{
    Echo("Hi!");

    yield return 2; // defines how many seconds to wait until it triggers the next part.

    Echo("Soo....");

    // if you ever need to end the coroutine mid-way you can use:
    //   yield break;
    // which is equivalent to `return` in a normal method.

    yield return 0.5;

    Echo("what's up?");

    // example of another coroutine executed inside a coroutine and have its yields passed onto this one.
    foreach(double wait in SubroutineExample())
        yield return wait;

    Echo("anyway, let's count seconds... forever!");

    yield return 1;

    int count = 1;

    // this will loop forever but because of the yield it won't try to do it all as fast as possible which would've caused a "script too complex".
    while(true)
    {
        Echo($"{count.ToString()}...");
        count++;

        yield return 1;
    }
}

IEnumerable<double> SubroutineExample()
{
    // this part runs immediately

    yield return 1.2;

    Echo("..");
    yield return 0.8;

    Echo("....");
    yield return 1.2;

    Echo(".................");
    yield return 0.4; // not required as the last statement

    // here it exits the foreach and continues with the code that is after foreach
}



/// <summary>
/// Quick usage:
/// <para>1. A persistent instance for each sequence you want to run in parallel.</para>
/// <para>2. Create instance(s) in Program(), assign Sequence, call Start().</para>
/// <para>3. Call <see cref="Run"/> in Main(), ensure PB's Main() gets called (Runtime.UpdateFrequency).</para>
/// </summary>
public class SimpleTimerSM
{
    public readonly Program Program;

    /// <summary>
    /// <para>Returns true if a sequence is in progress.</para>
    /// <para>False if it ended, got stopped or no sequence is assigned anymore.</para>
    /// </summary>
    public bool Running { get; private set; }

    /// <summary>
    /// <para>The sequence used by Start(). Can be null.</para>
    /// <para>Setting this will not automatically start it.</para>
    /// </summary>
    public IEnumerable<double> Sequence { get; set; }

    private IEnumerator<double> StateMachine;
    private double WaitTime;

    /// <summary>
    /// </summary>
    /// <param name="program">Pass the Program instance, `this`</param>
    /// <param name="sequence">Optional because it can be set later using Sequence property.</param>
    /// <param name="start">Simply calls Start() if true.</param>
    public SimpleTimerSM(Program program, IEnumerable<double> sequence = null, bool start = false)
    {
        if(program == null)
            throw new ArgumentNullException("program");

        Program = program;
        Sequence = sequence;

        if(start)
            Start();
    }

    /// <summary>
    /// <para>Starts or restarts the sequence declared in Sequence property.</para>
    /// <para>Throws an exception if the Sequence is null; use Stop() instead if you intend to stop it.</para>
    /// <para>If it's already running, it will be stoped and started from the begining.</para>
    /// <para>Don't forget to set Runtime.UpdateFrequency and call this class' Run() in Main().</para>
    /// </summary>
    public void Start()
    {
        if(Sequence == null)
            throw new Exception("Cannot start, Sequence is null");

        SetStateMachine(Sequence);
    }

    /// <summary>
    /// <para>Stops the sequence from progressing any further.</para>
    /// <para>Calling Start() after this will start the sequence from the begining (the one declared in Sequence property).</para>
    /// </summary>
    public void Stop()
    {
        SetStateMachine(null);
    }

    /// <summary>
    /// <para>Call this in your Program's Main() and have a reasonable update frequency, usually Update10 is good for small delays, Update100 for 2s or more delays.</para>
    /// <para>Checks if enough time passed and executes the next chunk in the sequence.</para>
    /// <para>Does nothing if no sequence is assigned or it's ended.</para>
    /// </summary>
    public void Run()
    {
        if(StateMachine == null)
            return;

        // game time between the previous PB run and now, subtract it from the waiting time.
        WaitTime -= Program.Runtime.TimeSinceLastRun.TotalSeconds;
        if(WaitTime > 0)
            return; // there's more time to wait, skip!

        // executes the next part in the sequence.
        // returns true if `yield return` was used.
        // returns false if it reached the end or `yield break` was used.
        bool hasValue = StateMachine.MoveNext();

        if(hasValue)
            WaitTime = StateMachine.Current; // value from `yield return`
        else
            SetStateMachine(null); // cleanup
    }

    private void SetStateMachine(IEnumerable<double> newSeq)
    {
        Running = false;
        WaitTime = 0;

        StateMachine?.Dispose();
        StateMachine = null;

        if(newSeq != null)
        {
            Running = true;
            StateMachine = newSeq.GetEnumerator();
        }
    }
}



This tutorial was adapted from the original on Malware's MDK wiki, by the author.