The Well Oiled Machine
What we've done so far can enable some pretty impressive terminals, but there's a snag. Up until now, the terminals we've made have been more or less restricted to a single screen. This can be enough for things like simple interfaces and read-outs, but is limiting when you want to deal with more complex setups. Now it's time to learn how to use a powerful feature of the ImGui system by utilizing it's most common implementation.
Table of Contents
The Basics
Getting More Advanced
Using SOMA's Built-In GUI Styles
- StationGui (WIP)
- UrbanGui (WIP)
- Playing Audio (WIP)
Going Beyond Terminals
- Setting Up a User Module (WIP)
- Basic Heads-Up Display (WIP)
- Target Info Module (WIP)
- Player HUD Menu System (WIP)
Tutorial Requirements
For this tutorial, you will need the following:
- A map with a prepared terminal, plus all the necessities.
- A script file with a prepared OnGui terminal callback function.
Tutorial Source Files
(I'm currently out of state for Thanksgiving. I'll upload the source files when I'm on my main machine again.)
The Tutorial Itself
I assume at this point of the tutorial that you have a pretty good understanding of what variables are within a programming context, but for the sake of this tutorial, here's a brief rundown.
A variable is a virtual construct within a program that allows you to store any value for use later within that program. Through various commands and expressions, this value can be modified, passed around, referenced, or used as a condition. The existence of variables are what allows all but the absolute simplest programs to be possible.
ImGui state variables expand on this principle by allowing you to use variables that are closely linked with the ImGui system itself. Their usage is pretty straightforward, and similar to using a dictionary data structure. The basis of how to interface with state variables works around a getter/setter pair system:
ImGui_SetStateInt("VariableName", lValue);
int lValue = ImGui_GetStateInt("VariableName");
The function in the first line of code,
ImGui_SetStateInt, is the setter part of the pair, while the second function,
ImGui_GetStateInt, is the getter. These together form the pair of functions that deal with integer state variables. Along with this pair, there are also pairs that deal with booleans (
ImGui_GetStateBool,
ImGui_SetStateBool), floats (
ImGui_GetStateFloat,
ImGui_SetStateFloat), colors (
ImGui_GetStateColor,
ImGui_SetStateColor), and third-degree vectors (
ImGui_GetStateVector3f,
ImGui_SetStateVector3f).
These state variables have a lot of potential uses, but by far their biggest use is in implementing what is known as a
state machine. There are some nuances to what a state machine is, but how it works in a nutshell is that you store the current state of the program in a flag variable (usually an int), and then you branch your logic into a number of different states based on that flag.
State machines are used all over the place in SOMA's terminals. The different branches can be called a number of things, such as states, branches, screens, windows, and so on, but the terminology that FG seems to use is to call them "apps", so that's what I'm going to use as well for the sake of simplicity.
The implementation of a state machine is pretty straightforward. It consists of sending the aforementioned integer flag into a switch statement, having each of the separate apps be processed in its corresponding case block. For organizational reasons, each app is also encapsulated as its own separate function that is called from the case block, leaving the logic of the state machine itself to be clean and simple to follow.
But I think that's enough about the theory. Let's get into the code.
Before we get into the terminal code itself, we need to set up our app flag values. The simplest way to do this is to use an enum structure, with each separate enum value representing a different app for our state machine.
enum eTerminalState
{
eTerminalState_Main,
eTerminalState_First,
eTerminalState_Second,
eTerminalState_Third
}
As you can see, we have four total apps for our terminal to use - the main app (which will be the default app), and three branching apps. The naming convention SOMA uses for these enum values is to use the letter "e" (for enum), the name of the terminal that will use the enum, then the word "State", followed by an underscore and the name of the app itself. (Our terminal doesn't have a specific name here, so we will just call it "Terminal".)
An important thing to note here is that when you create enums, the enum script needs to be outside any class. The simplest place to put it, then, is just underneath your "#include" section, before your map's class declaration. For example, after creating your enum, the top of your script should look something like the following:
#include "base/Inputhandler_Types.hps"
#include "helpers/helper_map.hps"
#include "helpers/helper_props.hps"
#include "helpers/helper_effects.hps"
#include "helpers/helper_audio.hps"
#include "helpers/helper_imgui.hps"
#include "helpers/helper_sequences.hps"
#include "helpers/helper_game.hps"
#include "helpers/helper_modules.hps"
#include "helpers/helper_ai.hps"
//--------------------------------------------------
enum eTerminalState
{
eTerminalState_Main,
eTerminalState_First,
eTerminalState_Second,
eTerminalState_Third
}
//--------------------------------------------------
class cScrMap : iScrMap
{ ...
Now that the enum has been created, let's look back at the terminal code. What we want to do is create our state machine inside our terminal's main OnGui function. The basic steps of doing this is to get the value of the current app, then run that value through our switch statement:
int lActiveApp = ImGui_GetStateInt("Terminal_CurrentApp");
switch (lActiveApp)
{
case eTerminalState_Main:
break;
case eTerminalState_First:
break;
case eTerminalState_Second:
break;
case eTerminalState_Third:
break;
}
Not too terrible, right? However, there is one problem that hasn't been addressed yet. As you can see, on every update loop of our terminal, we are receiving the current value of the state variable "Terminal_CurrentApp". But what happens the first time this code is run? We need a way to tell SOMA what the default app is, the first time the terminal is run. The initial guess would be to put our initialization code in the map's OnEnter or OnStart functions, but the problem with that is ImGui functions do not work outside of an ImGui context, so any attempt to call ImGui_SetStateInt outside our OnGui function will result in an error. (There are a few exceptions to this rule, such as the
ImGui_PreloadImage function that we have used in the past.)
This is where the function
ImGui_IsFirstRun comes in. This function will run true only if the current update loop iteration is the first time this terminal's update loop has been processed. Using this function, we can make sure that our crucial ImGui-specific code gets run at the beginning of every terminal's lifespan. Put the following code at the top of your terminal's OnGui function:
if (ImGui_IsFirstRun())
{
ImGui_SetStateInt("Terminal_CurrentApp", eTerminalState_Main);
}
Now that that little oversight is sorted out, let's return our focus back to the state machine.
What we want to do now is to write the logic for our individual apps. As I've said before, the best way to do this is to put the logic for each app in its own function that we call from the state machine, so let's do that. Add the following functions to your map code, just after the terminal's OnGui function:
void OnGui_Terminal_Main(const tString &in asEntityName, float afTimeStep)
{
}
//-------------------------------------------------------
void OnGui_Terminal_First(const tString &in asEntityName, float afTimeStep)
{
}
//-------------------------------------------------------
void OnGui_Terminal_Second(const tString &in asEntityName, float afTimeStep)
{
}
//-------------------------------------------------------
void OnGui_Terminal_Third(const tString &in asEntityName, float afTimeStep)
{
}
These functions will provide a nice isolated environment to handle our individual apps from. From looking at the function signatures, you can see that I included the same parameters in these functions that were present in the main OnGui function. While this isn't required, it does provide a consistent convention across all of our separate GUI functions. This is generally good form to do in your code, as it helps you know at a glance what the purpose of your function is.
Now that we have our functions created, let's change our state machine to call them at the appropriate times:
switch (lActiveApp)
{
case eTerminalState_Main:
OnGui_Terminal_Main(asEntityName, afTimeStep);
break;
case eTerminalState_First:
OnGui_Terminal_First(asEntityName, afTimeStep);
break;
case eTerminalState_Second:
OnGui_Terminal_Second(asEntityName, afTimeStep);
break;
case eTerminalState_Third:
OnGui_Terminal_Third(asEntityName, afTimeStep);
break;
}
Now that we've got our functions getting called from our state machine, it's time to actually put some functions in those functions.
But first, before we put code in the functions themselves, we are going to create a couple more functions into the code for helper reasons. Our apps will each be using buttons and/or labels, but we want to make sure that all our GUI elements look consistent with each other without creating repetitive code. Put the following two helper functions after our GUI functions.
cImGuiButtonData GetDefaultButtonData()
{
cImGuiButtonData data;
data.mFont.SetFile("sansation_large_bold.fnt");
data.mFont.mvSize = cVector2f(75);
data.mColorBase = cColor(0.4);
data.mColorInFocus = cColor(0.5);
data.mFontAlign = eFontAlign_Center;
return data;
}
cImGuiLabelData GetDefaultLabelData()
{
cImGuiLabelData data;
data.mFont.mvSize = cVector2f(80);
return data;
}
That should all look like familiar code to you now. All this is doing is creating a single configuration for label data and button data that we can use repeatedly in our code without creating potentially tiresome or error-prone copy-paste code. Now that that's out of the way, let's get back to our GUI code.
Let's start with the main app. From here, we are simply going to create three different buttons that act as our navigation to the other three apps. In order to do this, for each button, we change the flag for the current app when the button is pressed.
void OnGui_Terminal_Main(const tString &in asEntityName, float afTimeStep)
{
cImGuiButtonData buttonData = GetDefaultButtonData();
if (ImGui_DoButtonExt(
"App 1 Button",
"App 1",
buttonData,
cVector3f(312, 125, 0),
cVector2f(400, 110)))
{
ImGui_SetStateInt("Terminal_CurrentApp", eTerminalState_First);
}
if (ImGui_DoButtonExt(
"App 2 Button",
"App 2",
buttonData,
cVector3f(312, 325, 0),
cVector2f(400, 110)))
{
ImGui_SetStateInt("Terminal_CurrentApp", eTerminalState_Second);
}
if (ImGui_DoButtonExt(
"App 3 Button",
"App 3",
buttonData,
cVector3f(312, 525, 0),
cVector2f(400, 110)))
{
ImGui_SetStateInt("Terminal_CurrentApp", eTerminalState_Third);
}
}
Now for the rest of the apps. For these, we are simply going to show a label that displays the current app.
void OnGui_Terminal_First(const tString &in asEntityName, float afTimeStep)
{
ImGui_DoLabelExt(
"This is the first app.",
GetDefaultLabelData(),
cVector3f(112, 75, 0),
cVector2f(400, 110));
}
//-------------------------------------------------------
void OnGui_Terminal_Second(const tString &in asEntityName, float afTimeStep)
{
ImGui_DoLabelExt(
"This is the second app.",
GetDefaultLabelData(),
cVector3f(112, 75, 0),
cVector2f(400, 110));
}
//-------------------------------------------------------
void OnGui_Terminal_Third(const tString &in asEntityName, float afTimeStep)
{
ImGui_DoLabelExt(
"This is the third app.",
GetDefaultLabelData(),
cVector3f(112, 75, 0),
cVector2f(400, 110));
}
You can try the code as it is now, but if you do, you might see there is a problem. Currently, once you enter one of the other apps, there's no way to get back to the main app. In order to fix this, we need to put in a back button for the player to click to return from their current app. There are a few ways to do this, but the most common way in SOMA that I've seen involves creating a common button for all the apps to use, then enabling or disabling it whenever appropriate. Change the code in the main terminal OnGui function to show the following:
int lState = ImGui_GetStateInt("Terminal_CurrentApp");
int lBackState = eTerminalState_Main;
bool bBackEnabled = false;
switch(lState)
{
case eTerminalState_Main:
OnGui_Terminal_Main(asEntityName, afTimeStep);
break;
case eTerminalState_First:
OnGui_Terminal_First(asEntityName, afTimeStep);
bBackEnabled = true;
break;
case eTerminalState_Second:
OnGui_Terminal_Second(asEntityName, afTimeStep);
bBackEnabled = true;
break;
case eTerminalState_Third:
OnGui_Terminal_Third(asEntityName, afTimeStep);
bBackEnabled = true;
break;
}
if (bBackEnabled)
{
if (ImGui_DoButtonExt(
"backButton",
"Back",
GetDefaultButtonData(),
cVector3f(45, 650, 1),
cVector2f(325, 115)))
{
ImGui_SetStateInt("Terminal_CurrentApp", lBackState);
}
}
There are a few changes here. First, we added a couple new variables called lBackState and bBackEnabled, with lBackState set to eTerminalState_Main and bBackEnabled to false by default. Then in all the apps other than the main app, we set bBackEnabled to true. And finally, we check if bBackEnabled is set to true, and if so, we create a button that will change the current app to the app specified in lBackState (which in this case, is our main app).
And that just about covers the basics of using the ImGui state variable system in implementing a state machine. State machines are very powerful in terms of what they enable the program to do with relative ease and an immense amount of organization. In the next tutorial, we are going to learn about a couple of the less common and more complex ImGui controls.
Extra Credit
In this case, our state machine had a single main app, and a bunch of branching apps that returned to the main one. How else might a state machine be structured?