wixette / isb

Interactive Small Basic (ISB) - Simple scripting language to be embedded in Unity games or shell environments.
https://www.nuget.org/packages/ISB/
Apache License 2.0
24 stars 3 forks source link

More control over engine steps #28

Open ratkingsminion opened 2 years ago

ratkingsminion commented 2 years ago

Thank you for supporting coroutines now! It might be my own use case is a bit more special, but I want to use ISB for turn-based robots. This way I could write, for example, a program like this:

While True
    robot.WalkForward()
    robot.Turn90DegreesRight()
EndWhile

WalkForward() and Turn90DegreesRight() pause the execution of the program and let the game do some transitions; so they automatically call an internal Tick() method. As this is an infinite loop, the robot would now walk in a circle forever. In my game Terrobot I achieved this by adding finer control to the ISB engine, so I can run the engine for a single step only, and continue it in the same frame if I wanted, or in the next. With the current implementation (with the Coroutine as parameter) I couldn't find out how to achieve the same. I'd understand though if that's out of the scope of ISB.

wixette commented 2 years ago

Can you show me some code what is your preferred way to achieve this? You mentioned that you have made a Continue() API as well as the xyz.Break() function that a script can invoke. I'd like to have a couple of code lines to better understand your idea. Thanks!

ratkingsminion commented 2 years ago

Sure! Mind, this was with an older version of ISB, and I didn't have a look at the current source code. But for Terrobot I changed the following methods of Engine.cs (please be aware that this is hastily written jam code, and can definitely be achieved in a saner way):

       public bool Run(bool reset, bool stepping = false)
        {
            if (reset)
            {
                // The env is reset while the compilation result (assembly, codelines) is kept.
                this.env.Reset();
            }
            this.diagnostics.Reset();
            return this.ExecuteAssembly(stepping);
            // return !this.HasError;
        }

        private bool ExecuteAssembly(bool stepping)
        {
            // Scans for new labels and updates the label dictionary.
            for (int i = this.env.IP; i < this.assembly.Instructions.Count; i++)
            {
                var instruction = this.assembly.Instructions[i];
                if (instruction.Label != null)
                {
                    this.env.RuntimeLabels[instruction.Label] = i;
                }
            }

            // Executes instructions one by one.
            bool always = !stepping;
            while ((always || stepping) && this.env.IP < this.assembly.Instructions.Count && !this.HasError)
            {
                stepping = false;
                Instruction instruction = this.assembly.Instructions[this.env.IP];
                try
                {
                    ExecuteInstruction(instruction);
                }
                catch (TargetInvocationException e)
                {
                    if (e.InnerException is OverflowException)
                        this.ReportOverflow(e.Message);
                    else
                        this.ReportRuntimeError(e.InnerException.Message);
                }
                catch (OverflowException e)
                {
                    this.ReportOverflow(e.Message);
                }
                catch (StackOverflowException e)
                {
                    this.ReportStackOverflow(e.Message);
                }
            }

            return this.env.IP < this.assembly.Instructions.Count && !this.HasError;
        }

And I added these two methods:


        public bool Continue()
        {
            return this.ExecuteAssembly(true);
        }

        public float GetPercentage() {
            float i = this.env != null ? this.env.IP : 0f;
            float max = (this.assembly?.Instructions != null) ? this.assembly.Instructions.Count : float.MaxValue;
            return i / max;
        }

(GetPercentage() proved to be less informative than I hoped, just added it for fun, to see at which position of the program the robot currently is. Of course that doesn't work well with the Small Basic code being actually being compiled to some kind of machine code.)

And this is how I use this in my own code:

        void EvaluateCode(string code, bool reset) {
            if (reset || engine == null) { engine = new Engine("Unity", new System.Type[] { typeof(Rob) }); }

            try {
                if (engine.Compile(code, false)) {
                    StartCoroutine(EvaluateCodeCR());
                }
                else {
                    Output("<color=red>ERROR(s) while Compiling!</color>");
                    foreach (var content in engine.ErrorInfo.Contents) {
                        Output("<" + content.Range.Start + "> " + content.ToDisplayString());
                    }
                }
            }
            catch {
                Output("<color=red>Serious ERROR(s) while Compiling!</color>");
            }
        }

        IEnumerator EvaluateCodeCR() {
            bool done = false;
            evaluating = true;
            try {
                if (engine.Run(false, true)) {
                    while (IsRunning || paused) { yield return null; }
again:
                    var time = Time.realtimeSinceStartupAsDouble + 0.25;
                    while (evaluating && engine.Continue() && time > Time.realtimeSinceStartupAsDouble) {
                        // Debug.Log(evaluating +" .b.. " + engine.Continue() +" ... " + Time.realtimeSinceStartupAsDouble);
                        while (IsRunning || paused) { yield return null; }
                    }
                    if (evaluating && time <= Time.realtimeSinceStartupAsDouble) {
                        yield return null;
                        goto again; // yes, I'm sorry. as I said, jam code
                    }
                    done = evaluating;
                }
            }
            finally {
            }

            evaluating = false;

            if (engine.HasError) {
                Output("<color=red>ERROR(s) while Running!</color>");
                foreach (var content in engine.ErrorInfo.Contents) {
                    Output("<" + content.Range.Start + "> " + content.Range.ToDisplayString());
                }
            }
            else if (done && engine.StackCount > 0) {
                string ret = engine.StackTop.ToDisplayString();
                while (IsRunning || paused) { yield return null; }
                Output("<color=green>DONE.</color> " + ret);
            }
            else if (done) {
                while (IsRunning || paused) { yield return null; }
                Output("<color=green>DONE.</color>");
            }
        }

...with these button events:

        public void OnButton_Stop() {
            evaluating = false;
            paused = false;
            Time.timeScale = 1f;
        }

        public void OnButton_Pause() {
            paused = true;
            Time.timeScale = 0f;
        }

        public void OnButton_Play() {
            RunCode(uiInput.text);
        }

So there I didn't have some kind of Break() command for my ISB code (not needed for the game), but it would be easy to add it. In any case, every ISB robot command like Shoot() or MoveForward() calls a static Tick() method which basically sets IsRunning to true as long as the robot's and enemies' movements and other things are executed. It was a bit overcomplicated, so I'm not sure how helpful my example is. Maybe I can create simpler version of it later.

wixette commented 2 years ago

Thanks for the example code! It's very helpful. There are three things as I understand:

  1. A step and continue execution API, as what you have done in Run() and Continue().

I can make an update to support a similar API.

  1. A diagnostic interface to locate the current BASIC code line, as you show an inaccurate percentage number for informative purposes.

During the execution, the line no of the BASIC source code, instead of the compiled machine code, can be retrieved via Assembly.SourceMap.

I can add this support to ISB as well.

  1. BASIC code can invoke a library function such as xyz.Shoot() to start an in-game action, wait until the action finishes without blocking the game's main loop, then move on to the next instruction.

I guess a typical xyz.Shoot() will start a coroutine or flip an animation trigger in Unity and return to ISB immediately. The expected behavior is to pause the execution of the BASIC code until the coroutine or the animation finishes. - Let me know if my understanding is not the case.

Using a flag like "IsRunning" to pause the ISB execution looks like a quick fix to achieve this. Let me try some other ideas, e.g., a C# attribute to specify that a library function will pause ISB to wait for a signal (e.g., via a callback), without blocking the Unity game.

ratkingsminion commented 2 years ago
3. BASIC code can invoke a library function such as xyz.Shoot() to start an in-game action, wait until the action finishes without blocking the game's main loop, then move on to the next instruction.

I guess a typical xyz.Shoot() will start a coroutine or flip an animation trigger in Unity and return to ISB immediately. The expected behavior is to pause the execution of the BASIC code until the coroutine or the animation finishes. - Let me know if my understanding is not the case.

You are correct - the xyz.Shoot() in my specific case (as I'd expect from similar "robot programming games" too) pauses ISB execution because the game is turn-based (like a lot of "blobbers" of old times, for example); but usually of course it would be different.

Using a flag like "IsRunning" to pause the ISB execution looks like a quick fix to achieve this. Let me try some other ideas, e.g., a C# attribute to specify that a library function will pause ISB to wait for a signal (e.g., via a callback), without blocking the Unity game.

Having methods like Engine.Pause() and Engine.Unpause() (and Engine.Stop()) could in be an interesting alternative!

wixette commented 2 years ago

Update:

  1. A step and continue execution API, as what you have done in Run() and Continue().
  2. A diagnostic interface to locate the current BASIC code line, as you show an inaccurate percentage number for informative purposes.
  3. BASIC code can invoke a library function such as xyz.Shoot() to start an in-game action, wait until the action finishes without blocking the game's main loop, then move on to the next instruction.

Engine.RunAsCoroutine has already supported the stepping interface, with the maxInstructionsPerStep parameter.

I added PauseCoroutine and ResumeCoroutine interfaces to the ISB engine. The two methods can be invoked either in the stepCallback function or in an extended library function, to control the engine behavior.

I also added a public property CurrentSourceTextRange to let the client know the code location (in source code line/col range) where the engine is executing. It can be checked every time stepCallback is called during a coroutine.

I created a new Unity integration demo at https://github.com/wixette/isb/tree/main/unity_integration_demos/TurnBasedExample to show how to use PauseCoroutine, ResumeCoroutine and CurrentSourceTextRange.

@ratkingsminion Please kindly take a look at the demo and let me know if it meets your requirements.

Basically, the ISB engine is designed as a simple and small embedded component. It doesn't provide complex interactions with its hosting environment by design - considering some relatively heavy-weight solutions such as MonoSharp or xLua, they are able to do the bi-directional invocations and coroutine executions across Lua and C# - that's the reason why ISB needs such kind of Pausing/Resuming tricks I guess. A later approach for ISB is to rethink this logic and try if there is something more elegant in between.

ratkingsminion commented 2 years ago

Thanks a lot for the hard work! I had a look at the project and its code, and it definitely is a solution that I can use for my own project. I hope I can soon integrate the updated ISB, and if something comes up, I will be able to give more feedback if needed.