The Inspiration

Early mockup of how the GUI should work for our project. Screenshot from Diablo 3.

The Implementation

I first broke down the different components of the GUI system:

  • GUIElement – Contains an interactable AABB box, a sprite, and has a type so that it can be updated or send an event when interacted with.
  • GUIScene – Each scene contains all elements relevant to each other, for example the player’s HUD or the main menu.
  • GUIHandler – The class that has ownership of all things GUI related. Handles creation and loading of scenes. All incoming and outgoing communication is event driven.

I was creating scenes and elements at startup in the previous project, so it was simple enough to call the same functions when creating them in the editor. I only had to add the Remove functions.

// GUIHandler class
void CreateGUIScene(const std::string& aSceneName, const size_t aNumberOfElementsToReserve);
void RemoveGUIScene(const std::string& aSceneName);
void AssignSpriteToGUIElement(GUIElement& aElement,
                              Graphics* aGraphics,
                              const Vector2f& aElementSize,
                              const std::string& aSpritePath);
		
// GUIScene class
GUIElement& CreateGUIElement(const Vector2f& aSize,
			                       const Vector2f& aOffset,
			                       const Vector2f& aScreenResolution,
			                       const eGUIElementType aGUIElementType,
			                       const bool aIsClickable,
			                       const eAlignType aAlignType,
			                       const eProgressionDirection aDirection = eProgressionDirection::None);
void RemoveElement(const size_t aIndex);

All scenes are then stored in an unordered map because its contents will remain static and memory will not be an issue.

The active scenes are stored as pointers in a deque so that I easily can push and pop scenes and render them in the correct order. The reason I chose a deque instead of a stack is purely out of convenience when clearing the deque or removing a specific scene.

std::unordered_map<std::string, GUIScene> myGUISceneMap = {};
std::deque<GUIScene*> myActiveGUIScenes = {};

The first element

I had to start somewhere. So I created a GUIEditor class that could be easily hooked into our editor. From there I called the function to create a scene and an element, and a few exposed variables so that I could edit the size, position and image of the element.

Save & load

The next thing I did was to setup saving and loading functionality to a json file format. I made a GUIFile class with a Save and a Load function to save data to a text format that can be easily edited if needed. There are buttons to save and load in the editor. Scenes and elements can be renamed, the editor makes sure the name is unique.

The data structures to save and load scenes and elements:

struct GUIElementData
{
	  // Element
	  std::string myName;
	  std::string myTexturePath;
	  std::string mySecondaryTexturePath;
	  int myType;
	  int myAlignType;
	  int myProgressionDirection;
	  bool isButton;
	  // Box
	  Vector2f myOffsetResolutionFactor;
	  Vector2f mySizeResolutionFactor;
};

struct GUISceneData
{
		std::string myName;
		std::vector<GUIElementData> myElements;
};

A scene and an element in json:

"name": "PlayerHud",
"elements": [
    {
        "name": "Portrait",
        "texturePath": "Data\\Assets\\UI\\02_UI_InGame\\ui_playerResource_container.dds",
        "secondaryTexturePath": "Data\\Assets\\UI\\02_UI_InGame\\ui_playerResource_container.dds",
        "type": 0,
        "alignType": 6,
        "progressionDirection": 1,
        "button": false,
        "offsetResolutionFactor": {
            "x": 0.00520833395421505,
            "y": 0.1666666716337204
        },
        "sizeResolutionFactor": {
            "x": 0.2083333134651184,
            "y": 0.18518516421318054
        }
    },

I chose to save the offset and size as a factor of the resolution so that I easily can change resolution and the GUI resizes automatically. There will be some floating point precision issues if the resolution is changed too many times, but it’s not obvious in this case and won’t cause any issues that affects gameplay.

Building the GUI

I wanted to make it possible for others in the group to be able to create and edit the GUI. I worked together with a graphical artist to create the scenes and with the workflow the GUI editor provided we finished blockouts of all the scenes in under an hour. All that was left was to iterate on the art and apply functionality to the different elements.

The GUIEditor class handles the ImGui editor window and its functionality. When creating a scene we first check if a scene already exists with that name, if so it adds _X at the end of the name corresponding to the number of the duplicate.

The scene is then selected and we can start creating elements in the scene.

if (ImGui::InputTextWithHint("##newGuiScene", "New GUIScene", sceneInputBuffer, 64,
			                              ImGuiInputTextFlags_EnterReturnsTrue))
{
		std::string sceneName = sceneInputBuffer;
		memset(sceneInputBuffer, 0, 64);

		RenameNotUniqueSceneName(myGUIHandler, sceneName);

		myGUIHandler->CreateGUIScene(sceneName);
		mySelectedGUIScene = &myGUIHandler->GetGUIScene(sceneName);
		myGUIHandler->PushGUIScene(sceneName);
}

Creating an element is similar, only we need to set a few default variables when creating the element and assign a default sprite. The resolution is passed as a parameter so that the element can calculate its resolution factor based on its current offset/size and original resolution.

if (ImGui::InputTextWithHint("##newGuiElement", "New GUIElement", elementInputBuffer, 64, 
		                              ImGuiInputTextFlags_EnterReturnsTrue))
{
		std::string elementName = elementInputBuffer;
		memset(elementInputBuffer, 0, 64);

		Vector2f size = { 100.0f, 100.0f };
		Vector2f offset = { 0.0f, 0.0f };
		Vector2f resolution = { (float)myGUIHandler->myGraphics->GetRenderSize().x, 
		                        (float)myGUIHandler->myGraphics->GetRenderSize().y };

		aGuiScene->CreateGUIElement(
				size,
				offset,
				resolution,
				KE::eGUIElementType::Decoration,
				false,
				KE::eAlignType::Center
		);

		RenameNotUniqueElementName(aGuiScene, elementName);

		aGuiScene->GetGUIElements().back().myName = elementName;

		myGUIHandler->AssignSpriteToGUIElement(
				aGuiScene->GetGUIElements().back(),
				myGUIHandler->myGraphics,
				{ 100.0f, 100.0f },
				"Data\\Assets\\PNGs\\coolguy.png");

		mySelectedGUIElement = &aGuiScene->GetGUIElements().back();
		mySelectedElementIndex = aGuiScene->GetGUIElements().size() - 1;
}

Functionality

When the cursor hovers over an element, it activates its Highlight function, and when it is pressed it activates its Click function. When the cursor stops hovering over an element its Reset function is called.

void GUIElement::Click()
{
    ES::EventSystem::GetInstance().QueueEvent(myEvent);
}

void GUIElement::Highlight()
{
    if (myState == eGUIElementState::Pressed)
		{
			  return;
		}
		myState = eGUIElementState::Hovered;

		if (mySecondaryTexture != myDisplayTexture)
		{
			  mySpriteBatch.myData.myTexture = mySecondaryTexture;
		}
}

void GUIElement::Reset()
{
		myState = eGUIElementState::Idle;

		if (mySecondaryTexture != myDisplayTexture)
		{
			  mySpriteBatch.myData.myTexture = myDisplayTexture;
		}
}

This is handled by the GUIHandler that gets the event from UserInput, the class that decides whether input should be sent as a GUIEvent or a PlayerEvent:

	/// -----------------------------------------------------------------------------------------------
	///	GUI EVENT
	if (GUIEvent* event = dynamic_cast<GUIEvent*>(&aEvent))
	{
			const Vector2f mousePosition = {
				  static_cast<float>(event->myMousePosition.x), static_cast<float>(event->myMousePosition.y)
			};

			/// -----------------------------------------------------------------------------------------------
			///	Pressed
			if ((event->myInputType == eInputType::LeftClick ||
				   event->myInputType == eInputType::RightClick) &&
				   event->myInteractionType == eInteractionType::Pressed)
			{
				  GUIElement* element = GetGUIElementFromMousePosition(mousePosition);
				  if (element && element->isButton)
				  {
					    element->Click();
				  }
			}

			/// -----------------------------------------------------------------------------------------------
			///	Released
			if ((event->myInputType == eInputType::LeftClick ||
				   event->myInputType == eInputType::RightClick) &&
				   event->myInteractionType == eInteractionType::Released)
			{
				  GUIElement* element = GetGUIElementFromMousePosition(mousePosition);
				  if (element && element->isButton)
				  {
					    element->Reset();
					    element->Highlight();
				  }
			}

			/// -----------------------------------------------------------------------------------------------
			/// Hovered
			if (event->myInteractionType == eInteractionType::Hovered)
			{
				  GUIElement* element = GetGUIElementFromMousePosition(mousePosition);
				  if (element)
				  {
					    element->Highlight();
				  }
			}
	}

Minimap

In our previous project I used a sprite batch for all the health bars above the enemies’ heads and figured I could do something similar for the minimap. So when an enemy spawns it sends an event with its position. The Minimap class then adds an instance to the sprite batch for the enemy icons. The enemies send a new event on an interval, as to not send them every frame.

Their position is converted to a position that fits the minimap. I’m sure there is a better way to do this, but what I came up with and what works for me is this:

myMapZeroPos = { myMap->myBox.myWidth * (1.0f - myFactorX),
                -myMap->myBox.myHeight * (1.0f - myFactorY) };
myScale = { myMap->myBox.myWidth / myScaleX,
            myMap->myBox.myHeight / myScaleY };
myMapOffset = myMapZeroPos + myMap->myBox.myScreenPosition;

auto& spriteBatch = myEnemy->mySpriteBatch.myInstances;

for (size_t i = 0; i < myEnemyPositions.size(); ++i)
{
		Vector3f scaledEnemyPosition =
		{
  			1.0f - myEnemyPositions[i].x * -scale.x + mapOffset.x,
	  		1.0f - myEnemyPositions[i].z * scale.y + mapOffset.y,
		  	0.0f,
		};

		if (i < spriteBatch.size())
		{
			  spriteBatch[i].SetPosition(scaledEnemyPosition);
			  spriteBatch[i].SetScale({ myEnemy->myBox.myWidth, 
			                            myEnemy->myBox.myHeight,
			                            1.0f });
		}
}
  1. I get the position inside the minimap that I consider to be (0,0).
  2. I set the scale according to what relation needs to be for the icons on the minimap.
  3. I then set the offset in relation to the minimap’s screen position.
  4. The scaled enemy position is then the enemy’s position converted from world space to minimap space.

Closing Thoughts

The GUI editor has made creating blockouts and refining the GUI a much simpler and faster process. But there are still some things I could improve for our next and final project, such as being able to easily create events for buttons and setting triggers on scenes or elements when they should show or hide. Currently I still have to do a lot of setup in code, but my goal is to be able to do everything inside the engine.


Next post