Simple Option Stacker - get started! API & Examples

2239375--149365--SimpleOptionStackerLogo1-624x225.png

Welcome, ladies and gentlement, to a tutorial series explaining how to use our new asset store tool, named the Simple Option Stacker!

What are Option Stacks?

An Option Stack is a system often used for the AI in most AAA games. Most commonly used in coherence with the classical Finite State Machine architecture, and the more advanced planner architecture, but also with most other architectures. As you probably know by now, most AIs are built up using states or behaviours. Each state defines how the AI should react to the current situation, and depending on the arcitecture, what conditions lead to other behaviours.

An Option Stack keeps a list of states that the AI is currently in. On top of the stack is the most recently called state. (Normally you would only execute the state that is ontop of the stack.)
This means all other states would temporarily suspend while the top state is being executed.

Let’s look at this example:
An RTS AI unit is ordered to attack player 2’s base. This will push the state “AttackBuilding()” to the top of the stack. However the unit is not whitin range, so it will also push “GoToLocation()” ontop of that. This means that once GoToLocation has finished, it will be removed from the stack, and the unit will proceed to AttackBuilding(). This can easily be hardcoded into an FSM, but things get exponentially more difficult when things get more complex.

Since while the unit is in the GoToLocation state, it walks into an ambush set up by player 3! Now the unit will push something like “ReactToAttack”. The first thing ReactToAttack requires is to take cover, so lets have that as a seperate state. That means “TakeCover” will be pushed ontop of the stack. But on its way to cover, the unit notices a live grenade thrown at its feet! Now it will push “ReactToGrenade” ontop of that. A state where the AIs only goal is to survive the grenade attack. The stack now looks like this:
“AttackBuilding < GoToLocation < ReactToAttack < TakeCover < ReactToGrenade”
The units ultimate goal is to complete the task on the very left, but to do so it must first complete every task in order from the right to the left!

Option Stack principles.

Every Option Stack system MUST contain 3 simple methods:
1. PushState - This will put the specified state ontop of the stack.
2. PopState - This will remove the top state from the stack.
3. GetTop - This will return the top state of the stack.

Every behaviour MUST pop itself after it has completed or is no longer relevant. This is easy, as any state can only pop itself using PopState, because only the top state can be exectued.

Note that at the moment our Simple Option Stacker natively supports states as methods and coroutines, also known as voids and IEnumerators, but the system can easily be modified to work with classes!

Tutorial post list:

Basic
1.1 How to start using Simple Option Stacker Basic
1.2 Features included in the basic edition

Remember that the newest version of Simple Option Stacker always will be available at lustrousgames.com/downloads before the asset store!

If you have found any problems with the pack, found an insignificant spelling error in a thread, or you want to make a suggestion/request for the next patch, feel free to post them on this, or preferrably this other thread, or send me a private message!

If you do not own Simple Option Stacker, feel free to purchase the package at either our website or on the asset store!

1.1: How to start using Simple Option Stacker Basic

Simple Option Stacker Basic contains only the essential Option Stack methods, and therefor is significantly cheaper than its extended counterpart.

To begin using any Simple Option Stacker version, you have to include its directive at the beginning of your scripts! This is very simple:

using OptionStack;

The second step in using Simple Option Stacker is to create a new stack as a variable. To start a Basic Stack put this in your scripts variable declaration:

public BasicStacker stack = new BasicStacker();

The last step in setting up Simple Option Stacker is to make sure the top state is beign executed as it should be. Normally this normally means calling the state every frame. As Extended 1.2 makes this alot easier, we will not go into differing between coroutines and invoking states, just remember that SOS’s getTop() function will return the top states name as a string! To execute a state method every frame we put this inside the scripts Update():

Invoke(stack.getTop(), 0);
//Or if you want to start a coroutine:
StartCoroutine(stack.getTop());

With Simple Option Stacker Extended you can easily update the stack using stack.Update() instead of manually invoking the state. Simple Option Stacker Extended, the only version currently available on the Asset Store, also will automatically pause your coroutines when they are not on top of the stack, and resume them where they left of when they are.

1.2: Basic features

Continuing with the written script, looking something like this:

using UnityEngine;
using System.Collections;
using OptionStacker;                                         //Include OptionStacker

public class BasicExample : MonoBehaviour {
    internal BasicStacker stack = new BasicStacker();        //Make a new stack

    void Update()    {
        Invoke(stack.getTop(), 0);                           //Manually invoke top state
    }
}

Simple Option Stacker Basic contains 4 features:
1. pushState(string nameOfState)

  • Calling pushState(“StateHere”) will push the specified state to the top of the stack. Method(string).
    2. popState()
  • Calling popState() will remove the top state from the stack. Method().
    3. getTop()
  • Calling getTop() will return the state ontop of the stack. Returns string.
    4. stackLength()
  • Calling stackLength() will return the length of the stack. Returns int.

Simple Option Stacker Extended is now available at the asset store!

Is there any sort of video tutorial of using this for a simple character or webplayer to try out? It looks good on paper but without anything really tangible, its hard to discern if this is something that will or will not help me. Thanks.

@Ghosthowl Im working on it. I want to put the RTS example scene on the webplayer, but I have to make a few minor changes to make that possible.

Also I have a video planned, but actually making it hasn’t been so easy, due to a lack of time and several problems with my PC. I should be able to get this up on youtube by the end of next week. This video will be how you can make a fairly advanced stealth action AI with the help of Simple Option Stacker, because I already have made this for a previous project. But if there are any other ideas, I see no reason why I wouldn’t be able to make more examples!

For the time being I can post the AI for the player controlled unit in the RTS example. It might be a bit complicated to read the attack section. That has nothing to do with Simple Option Stacker, but rather I don’t really know how to efficiently work with coroutines! My apologizes.
RTS Example - Player controlled AI

using UnityEngine;
using System.Collections;
using System.Collections.Generic;
using OptionStacker;                                        //Include OptionStacker package directive

[AddComponentMenu("AI/Simple Option Stacker/Examples/RTS AI")]
public class RTSAIScript : MonoBehaviour, IDamageable_SOS {
    ExtendedStacker stack = new ExtendedStacker();            //Make a new Extended Stacker and call it 'stack'
    NavMeshAgent agent;

    [Tooltip("What color will this AIs gunshots be?")]
    public Color attackColor = Color.blue;
    [Tooltip("How much health does this AI have?")]
    public float health = 100;
    [Tooltip("What is the maximum attack range for this AI?")]
    public float attackRange = 5;
    [Tooltip("How much damage does this AI do per shot?")]
    public float damage = 10;
    [Tooltip("How many bursts this unit fires before reloading.")]
    public int bursts = 3;
    [Tooltip("How many seconds does this AI use to reload?")]
    public float reload = 1;
  
    float IDamageable_SOS.Health    {
        get    { return health; }
    }

    [Header("Observe - Don't touch")]
    [Tooltip("This is the stack itself. The AI executes the behaviours from the bottom of the inspector to the top.")]
    public List<string> currentStack;
    [Tooltip("Fairly self-explanatory. Is this unit selected or not?")]
    public bool isSelected;

    private Vector3 startPos;
    private Vector3 endPos;

    void Awake()    {
        agent = GetComponent<NavMeshAgent>();

        stack.StartStack(this);                                //Initialize the stack
        stack.PushState("Idle");                            //Push default state
    }

    #region Selection
    void Update()    {
        if(Input.GetMouseButton(0) || Input.GetMouseButtonUp(0))    {
            if(Input.GetMouseButtonDown(0))    {
                startPos = Input.mousePosition;
            } else if(Input.GetMouseButtonUp(0)) {
                endPos = Input.mousePosition;
                CheckTarget();
            }
        }

        if(Input.GetMouseButtonUp(1)) {
            isSelected = false;
        }

//        if(stack.getTop == null)    {                        //This is sometimes a necessary check if the stack is reset at any time in your script. Use stack.SetBack() to preserve the base behaviour!
//            stack.PushState("Idle");                        //Idle is the default state
//        }

        stack.Update();                                        //Update the stack
        currentStack = stack.getStack;                        //Display the entire stack in the inspector
    }

    //This method will check what the player clicked
    void CheckTarget()    {
        if(startPos.sqrMagnitude > endPos.sqrMagnitude + 100 || endPos.sqrMagnitude > startPos.sqrMagnitude + 100)    {    //If this selection is a drag select
            #region Drag Select
            Camera mainCam = GameObject.Find("Main Camera").GetComponent<Camera>();                    //Refrence to the camera
            Ray rayX = mainCam.ScreenPointToRay(startPos);                                            //Convert the click position to world coordinates...
            Ray rayY = mainCam.ScreenPointToRay(endPos);                                            //... Through raycasting
            RaycastHit hitX, hitY;
            if(Physics.Raycast(rayX, out hitX))    {
                startPos = hitX.point;
                if(Physics.Raycast(rayY, out hitY))    {
                    endPos = hitY.point;
                } else {
                    isSelected = false;
                    return;
                }  
            } else {
                isSelected = false;
                return;
            }

            float tx = transform.position.x;
            float x1 = startPos.x;
            float x2 = endPos.x;
            if((x1 > tx && tx > x2) || (x2 > tx && tx > x1))    {                                    //If this AIs position is inside the drag selection on the X axis...
                float tz = transform.position.z;                                                    //We are converting from a Vector2 to a Vector3, so the orignal Y is actually the new Z
                float z1 = startPos.z;
                float z2 = endPos.z;
                if((z1 > tz && tz > z2) || (z2 > tz && tz > z1))    {                                //... And on the Y axis
                    isSelected = true;
                } else {
                    isSelected = false;
                    return;
                }
            } else {
                isSelected = false;
                return;
            }
            #endregion
        } else {                                                                                                        //Or if this selection is a click
            #region Click Select
            RaycastHit hit;
            Camera mainCam = GameObject.Find("Main Camera").GetComponent<Camera>();                        //Refrence to the camera
            Ray rayFromCam = mainCam.ScreenPointToRay(Input.mousePosition);                                //Make a ray from the clicked position
            if(Physics.Raycast(rayFromCam, out hit))    {
                string tag = hit.transform.tag.ToLower();                                                //Store the tag of the hit object for further
              
                if(hit.transform == transform)    {                                                        //Select this AI if it is clicked
                    isSelected = true;
                } else {
                    if(tag == "ground" && isSelected)    {                                                            //If the hit object is walkable, walk there
                        if(Input.GetKey(KeyCode.LeftShift))    {
                            if(stack.getTop == "WalkToLocation")    {
                                stack.QueState("WalkToLocation", stack.getAmount("WalkToLocation"), hit.point);        //Que command if shift is held  
                            } else {
                                stack.QueState("WalkToLocation", stack.getLength-1, hit.point);
                            }
                        } else {
                            stack.SetBack();                                                                //Replace the entire stack if shift isn't held down
                            stack.PushState("WalkToLocation", hit.point);                                    //SetBack will reset the entire stack except for the bottom state
                        }
                    } else if(tag == "enemy" && isSelected)    {                                            //If the hit object is an enemy, attack it
                        if(Input.GetKey(KeyCode.LeftShift))    {
                            stack.QueState("AttackTarget", stack.getLength-1, hit.transform);
                        } else {
                            stack.SetBack();
                            stack.PushState("AttackTarget", hit.transform);  
                        }
                    } else {
                        if(tag == "player" && !Input.GetKey(KeyCode.LeftShift))    {                                                            //Can select multiple units if shift is held down
                            isSelected = false;
                        }
                    }
                }
            } else {
                isSelected = false;
            }
            #endregion
        }
    }
    #endregion

    //In this script, every AI behaviour has been made public, this is NOT required!
    #region Idle
    public void Idle()    {
        //Since this is the default state it doesn't need to pop itself
    }
    #endregion

    #region Walk to Location
    public void OnWalkToLocation(Vector3 location)    {                                //OnFunctions/OnExitFunctions can take the same arguments as its main function, or none at all!
        agent.SetDestination(location);
        stack.PopState();
    }

    public void WalkToLocation(Vector3 location)    {                                //Do nothing but walk to the selected location
        Debug.DrawRay(location, Vector3.up, Color.blue);
        if(Vector3.Distance(transform.position, location) < 0.75f)    {                //Pop the WalkToLocation command when the location is aproximatly reached
            stack.PopState();
        }
    }
    #endregion

    #region Attack
                                                                                                    //Note that the On/OnExit methodtypes doesn't have to match the main methods type.
    public void OnAttackTarget(Transform target)    {                                                //A void Something can have an IEnumerator OnSomething etc!
        if(target.Equals(null) || target.gameObject.activeSelf == false)    {                        //If the target doesn't exist, dont attack it
            stack.PopState();
            stack.PopState();
        } else {
            if(Vector3.Distance(transform.position, target.position) > attackRange)    {                    //Check if the target is in attack range
                NavMeshHit navHit;
                Vector3 direction = (target.position - transform.position).normalized*(attackRange-1f);    //Prepear to find the shortest path to being in range of the target
                if(NavMesh.SamplePosition(target.position - direction, out navHit, 50, -1))    {            //Make sure the new position is on a navmesh
                    stack.PushState("WalkToLocation", navHit.position);                                    //Walk to the target if its not in attack range
                }
            } else {
                stack.PopState();                                                                        //If the target is in range, attack it
            }  
        }
    }

    public IEnumerator AttackTarget(object[] parameters)    {                        //Coroutines can only contain a single argument, which also must be an array of objects
        Transform target = parameters[0] as Transform;                                //A simple way to bypass this is to use the array of objects as the only parameter and explicitly convert the objects like this

        if(target.Equals(null))    {
            stack.PopState();
            yield return false;
        }

        IDamageable_SOS idmg = null;                                                                    //Check if the target implements the interface IDamagable

//        if(parameters.Length > 1)    {                                                                    //This is a simple way of doing optional arguments with coroutines
//            idmg = parameters[1] as IDamageable_SOS;                                                    //We do this because we restart the coroutine, and we don't want to redo the interface check
//        } else {
            foreach(MonoBehaviour checkInterface in target.gameObject.GetComponents<MonoBehaviour>())    {    //Find all scripts on the target and check for the interface
                if(checkInterface is IDamageable_SOS)    {                                                    //If target is damageble
                    idmg = checkInterface as IDamageable_SOS;                                                //Assign interface
                    break;                                                                                    //Only find the first damagable script
                }
            }  
//        }

        yield return new WaitForEndOfFrame();                                        //Give the target some time to Destroy() itself before attacking it

        if(target.Equals(null) || idmg == null || idmg.Health <= 0)    {                //If the target doesn't exist anymore or isn't damageable or is destroyed
            stack.PopState();                                                        //Stop attacking
            yield return false;
        }

        yield return new WaitForSeconds(.25f);                                        //First attack isn't instant
        object[] args = new object[]{ target, idmg};                                //Prepare the list of arguments for FireAt();
        for(int i = 0; i <= bursts; i++)    {
            stack.QueState("FireAt", i, args);                                        //QueState can work like PushState if the 'by amount' int is set to 0
        }

        for(int inf = 0; inf < 1; inf--)    {                                        //Use an infinite loop and the option stack to pause the coroutine while firing
            yield return new WaitForEndOfFrame();

            if(stack.getTop == "AttackTarget")    {                                    //Don't keep reloading if this isn't the current behaviour
                break;
            } else {
                inf = 0;
            }
        }

        yield return new WaitForSeconds(reload-.25f);                                //Fire 3 bursts before reloading
      
        if(idmg.Health > 0 && !target.Equals(null))    {                                //If the target is still alive
            stack.QueState("AttackTarget", 1, parameters);                            //Restart the coroutine manually
            stack.QueState("Stun", 1, 0.22f);                                        //Repurpose a behaviour! Give the game a frame to update when restarting coroutines manually!
        }

        stack.PopState();
    }
  
    public IEnumerator FireAt(object[] parameters)    {                                //Will fire at the first parameters position, and do damage to the second parameter.
        Transform target = parameters[0] as Transform;                                //Get the target
        IDamageable_SOS idmg = parameters[1] as IDamageable_SOS;                    //Get what script to damage

        yield return new WaitForEndOfFrame();

        if(target.Equals(null) || idmg.Health <= 0)    {                                //Make sure the target isn't already destroyed
            stack.PopState();
            yield return false;
        }

        Debug.DrawLine(transform.position, target.position, attackColor, 0.225f);    //Temporary quickfix for displaying laserbeams
        idmg.TakeDamage(damage, gameObject);                                        //Do the damage

        yield return new WaitForSeconds(0.25f);                                        //Wait a bit for realism
        stack.PopState();                                                            //Finish this behaviour
    }
    #endregion

    #region Health
    public void TakeDamage(float dmg, GameObject attacker)    {
        health -= dmg;                                                                //Damage the unit when it is attacked

        if(health <= 0)    {                                                            //See if the unit should die
            Destroy(gameObject);                                                    //Kill the unit
        }

        if(stack.getTop != "AttackTarget" && stack.getTop != "Stun")    {            //Only do this if the AI isn't in combat already:
            stack.PushState("AttackTarget", new object[1]{attacker.transform});        //Start attacking the attacker
            stack.PushState("Stun", dmg/10);                                        //Stun the unit when its attacked
        }
    }

    public IEnumerator Stun(object[] sec)    {                        //Stun the unit and suspend all actions for S seconds
        float s = (float)sec[0];                                    //Get S seconds from the parameters
        yield return new WaitForSeconds(s);                            //Wait S seconds
        stack.PopState();                                            //Finish the stun
        stack.StartCooldown("Stun", s);                                //When a unit has been stunned, they cannot be stunned again for atleast X seconds
    }
    #endregion  
}

RTS Example - Damage interface

//This is required for taking/dealing damage

using UnityEngine;
using System.Collections;

public interface IDamageable_SOS    {
    float Health { get; }

    void TakeDamage(float dmg, GameObject attacker);
}

In the progress of making a full stealth AI tutorial series! This series uses some of the Simple Option Stacker features, so check it out if you have time. Click here to check it out!

Update 1.4 is live! API coming ASAP.

@Ghosthowl I made a very simplistic example, and uploaded it as a webplayer! Click here to try it out.
Click to move. Shift-Click to que moves. Press F2 to make the scout wait for 2 seconds. The scout will wait for 1 second after every move, to showcase the OnExitFunction.