Tutorial Part 8: User Interface Manager

The process we’ve been following so far has been to first create a sketch of the intended behaviour in the GameLoop, which allows us to test it out. After the behaviour matches its intended purpose, we implement the sketch in a new class. In the last tutorial we started seeing more codebloat (and a bug!) in our GameLoop as we began to add UI features like map scrolling.

As we’ve done before, let’s create a new class that represents these features: the UIManager. This is going to be a large class by the time we’re done with it, but let’s imagine a few goals for the class:

We’re only going to implement a couple of those features today, by letting our UIManager take advantage of SadConsole’s ConsoleContainer class. Why are we using ConsoleContainer?

SadConsole’s ConsoleContainer class doesn’t do much. It is a parent that exists only to process the activities of its child consoles. It does not even need a width or a height specified in the constructor.

UIManager

As usual, we start by laying out a new skeleton class for UIManager:

using Microsoft.Xna.Framework;
using SadConsole;
namespace SadConsoleRLTutorial
{
    // Creates/Holds/Destroys all consoles used in the game
    // and makes consoles easily addressable from a central place.
    public class UIManager : ConsoleContainer
    {
        public Console MapConsole;

        public UIManager()
        {
            // must be set to true
            // or will not call each child's Draw method
            IsVisible = true;
IsFocused = true;

            // The UIManager becomes the only
            // screen that SadConsole processes
            Parent = SadConsole.Global.CurrentScreen;
        }
    }
}

In the above code we are storing a single console that will contain a map. MapConsole is public because the plan is to push data into it from another class that we’ll create later. The comment about IsVisible and IsFocused is important. IsVisible tells SadConsole to process the ScreenObject’s Draw method. IsFocused tells SadConsole to pay attention to any keyboard or mouse input.

Setting UIManager as the child of SadConsole.Global.CurrentScreen is required. This tells SadConsole that UIManager should be actively processed and displayed. Without this, you’ll just get a black screen.

Let’s add a new method to UIManager:

        // Creates all child consoles to be managed
        // make sure they are added as children
        // so they are updated and drawn
        public void CreateConsoles()
        {
            MapConsole = new SadConsole.Console(GameLoop.World.CurrentMap.Width, GameLoop.World.CurrentMap.Height, Global.FontDefault, new Rectangle(0, 0, GameLoop.GameWidth, GameLoop.GameHeight), GameLoop.World.CurrentMap.Tiles);

            // Don't forget to add the EntityManager to the MapConsole
            // or we won't be able to keep track of our actors
            MapConsole.Children.Add(GameLoop.EntityManager);
        }

Recognize this code? It is cut/pasted/modified from GameLoop. We’re creating our MapConsole here instead of in the GameLoop. Instead of using Width and Height properties, we’re now addressing them as GameLoop.GameHeight and GameLoop.GameWidth because we’re working outside of the GameLoop class, peeking into its public properties.

Notice that the very last parameter was changed from GameMap.Tiles to GameLoop.World.CurrentMap.Tiles. With this, we’re anticipating a new class called World that has the current Map stored in it. That means we’ll be building a new class to store our world data soon

While we’re still in the UIManager, let’s move some more code over from GameLoop and modify it to suit the new circumstances:

        // centers the viewport camera on an Actor
        public void CenterOnActor(Actor actor)
        {
            MapConsole.CenterViewPortOnPoint(actor.Position);
        }

        public override void Update(TimeSpan timeElapsed)
        {
            CheckKeyboard();
            base.Update(timeElapsed);
        }

        // Scans the SadConsole's Global KeyboardState and triggers behaviour
        // based on the button pressed.
        private void CheckKeyboard()
        {
            // As an example, we'll use the F5 key to make the game full screen
            if (SadConsole.Global.KeyboardState.IsKeyReleased(Microsoft.Xna.Framework.Input.Keys.F5))
            {
                SadConsole.Settings.ToggleFullScreen();
            }

            // Keyboard movement for Player character: Up arrow
            // Decrement player's Y coordinate by 1
            if (SadConsole.Global.KeyboardState.IsKeyPressed(Microsoft.Xna.Framework.Input.Keys.Up))
            {
                GameLoop.World.Player.MoveBy(new Point(0, -1));
                CenterOnActor(GameLoop.World.Player);
            }

            // Keyboard movement for Player character: Down arrow
            // Increment player's Y coordinate by 1
            if (SadConsole.Global.KeyboardState.IsKeyPressed(Microsoft.Xna.Framework.Input.Keys.Down))
            {
                GameLoop.World.Player.MoveBy(new Point(0, 1));
                CenterOnActor(GameLoop.World.Player);
            }

            // Keyboard movement for Player character: Left arrow
            // Decrement player's X coordinate by 1
            if (SadConsole.Global.KeyboardState.IsKeyPressed(Microsoft.Xna.Framework.Input.Keys.Left))
            {
                GameLoop.World.Player.MoveBy(new Point(-1, 0));
                CenterOnActor(GameLoop.World.Player);
            }

            // Keyboard movement for Player character: Right arrow
            // Increment player's X coordinate by 1
            if (SadConsole.Global.KeyboardState.IsKeyPressed(Microsoft.Xna.Framework.Input.Keys.Right))
            {
                GameLoop.World.Player.MoveBy(new Point(1, 0));
                CenterOnActor(GameLoop.World.Player);
            }
        }

Notice that I’ve removed the “static” keyword from these method definitions. As promised, we are slowly reducing the number of static methods as we create new homes for them in their own classes. 

We’ve also moved the Player object out of the GameLoop, into our World class. World will be where we store the player data – so any calls involving the player will require us to address it via GameLoop.World.Player.

Finally, take a look at the Update(TimeSpan time) method. This is a critical SadConsole-specific method that we are taking advantage of. The override keyword tells the compiler that we are extending the original method by adding some of our own code to it. In this particular case, the UIManager inherits methods from ConsoleContainer, which in turn inherits methods – eventually – from SadConsole.ScreenObject. This is where the original virtual Update(TimeSpan time) method lives.

Update is triggered before every single game frame update. Update is where you do all of your game logic processing before the Draw event is triggered. In this case, we’re using it to do two things:

  1. Checking the Keyboard for input.
  2. Calling the base method’s Update instructions afterwards.

The base.Update() call is critical, because without it our ConsoleContainer won’t update any of its children. A good rule of thumb for SadConsole is this: if your class inherits from SadConsole.Console or ScreenObject and you override an Update or Draw method, you must call base.Update or base.Draw at the end of your overriding method. Without it, SadConsole won’t know to update or draw its children and you’ll be wondering why your screen is blank and you’re living in a van down by the river.

World Class

World, in this context, takes on a wider meaning of the entire game state. So we won’t only be storing the map data – but also monster, NPC and player data too. We can also trigger map, monster and player generation from here too. If you remember the bad old FreeBSD UNIX days, I think of the World class as the place where we get to run make buildworld and sit back and drink some tea.

Per the usual way of doing things, we’ll begin with a skeleton class and move some code from the GameLoop into it.

using System;
using Microsoft.Xna.Framework;

namespace SadConsoleRLTutorial
{
    // All game state data is stored in World
    // also creates and processes generators
    // for map creation
    public class World
    {
        // map creation and storage data
        private int _mapWidth = 100;
        private int _mapHeight = 100;
        private TileBase[] _mapTiles;
        private int _maxRooms = 100;
        private int _minRoomSize = 4;
        private int _maxRoomSize = 15;
        public Map CurrentMap { get; set; }

        // player data
        public Player Player { get; set; }

        // Creates a new game world and stores it in
        // publicly accessible
        public World()
        {
            // Build a map
            CreateMap();

            // create an instance of player
            CreatePlayer();
        }

        // Create a new map using the Map class
        // and a map generator. Uses several 
        // parameters to determine geometry
        private void CreateMap()
        {
            _mapTiles = new TileBase[_mapWidth * _mapHeight];
            CurrentMap = new Map(_mapWidth, _mapHeight);
            MapGenerator mapGen = new MapGenerator();
            CurrentMap = mapGen.GenerateMap(_mapWidth, _mapHeight, _maxRooms, _minRoomSize, _maxRoomSize);
        }

        // Create a player using the Player class
        // and set its starting position
        private void CreatePlayer()
        {
            Player = new Player(Color.Yellow, Color.Transparent);
            Player.Position = new Point(5, 5);

            // add the player to the global EntityManager's collection of Entities
            GameLoop.EntityManager.Entities.Add(Player);
        }
    }
}

Here is the completed class after a Cut-and-Paste from the GameLoop. The above class stores room generation geometry, the currently running Map, and the Player.

CreateMap consists of the code that was previously in the GameLoop’s Init method. CreatePlayer is a direct cut-and-paste, with some changes to the variable names. Again, these are no longer marked as static methods because we’ll be creating an actual World object/instance that they can be called from.

One thing worth keeping an eye on are calls such as GameLoop.EntityManager.Entities.Add(Player). Read in english it says: “Add the player entity to the entity manager’s list of entities, which resides as a static object in the GameLoop.” Why does the EntityManager live in the GameLoop? It’s an architectural decision. I prefer to have anything management-related directly addressable from the GameLoop. I only plan to have one type of every manager, because something inside of me tells me that top-heavy organizations are fraught with cost overruns and corporate team-building exercises in Jamaica. Middle-management is a symptom of miscommunication between levels. That being said, if you really want to create your own “Manager of Managers”, you can! Just create a Manager class, and instantiate the EntityManager inside of that class. Then you call it via GameLoop.Manager.EntityManager.Entities.Add(Player). Did that make your code any more habitable? You get my drift.

GameLoop Cleanup

class GameLoop
    {

        public const int GameWidth = 80;
        public const int GameHeight = 25;

        // Managers
        public static SadConsole.Entities.EntityManager EntityManager;
        public static UIManager UIManager;

        public static World World;

        static void Main(string[] args)
        {
            // Setup the engine and create the main window.
            SadConsole.Game.Create("IBM.font", GameWidth, GameHeight);

            // Hook the start event so we can add consoles to the system.
            SadConsole.Game.OnInitialize = Init;

            // Hook the update event that happens each frame so we can trap keys and respond.
            SadConsole.Game.OnUpdate = Update;
                        
            // Start the game.
            SadConsole.Game.Instance.Run();

            //
            // Code here will not run until the game window closes.
            //
            
            SadConsole.Game.Instance.Dispose();
        }
        
        private static void Update(GameTime time)
        {

        }

        private static void Init()
        {
            //Instantiate the EntityManager
            EntityManager = new SadConsole.Entities.EntityManager();

            //Instantiate the UIManager
            UIManager = new UIManager();

            // Build the world!
            World = new World();

            // Now let the UIManager create its consoles
            // so they can use the World data
            UIManager.CreateConsoles();
        }

Notice how clean our GameLoop has become, now that we’ve moved almost all of the game state and game logic out of it, into custom classes?

Things worth nothing in the above code: we create a static UIManager and World. They each are created in the Init method, and then the UIManager is told to create its consoles as a final step. The order of these instructions is very important, because the UIManager must be created before the World can be created. Why? Because when the Player is created by World, it expects to find a working EntityManager.Entities that it can add the player to. Finally, UIManager.CreateConsoles must be run as a last step, because it requires World to have a generated CurrentMap ready-at-hand.

Actor Edit

A quick change in the Actor class is now necessary, to reflect that the World class now holds the map data:

        // Moves the Actor BY positionChange tiles in any X/Y direction
        // returns true if actor was able to move, false if failed to move
        public bool MoveBy(Point positionChange)
        {
            // Check the current map if we can move to this new position
            if (GameLoop.World.CurrentMap.IsTileWalkable(Position + positionChange))
            {
                Position += positionChange;
                return true;
            }
            else
                return false;
        }

To be honest, I’m not 100% comfortable with this degree of fragility in our game. When a single instruction is put in the wrong order, the compiler will find¬†null¬†data or a missing object and asplode. There are better ways of engineering game initialization, but for now we have a working machine. All that refactoring now allows us to do something cool with it!

Draggable Windows

It’s about time we added some kind of windowing UI to this project, so the map only takes up a portion of the screen.

Add this definition to the UIManager:

public Window MapWindow;

Modify CreateConsoles so it only creates consoles instead of adding them as children of the UIManager:

        // Creates all child consoles to be managed
        public void CreateConsoles()
        {
            MapConsole = new SadConsole.Console(GameLoop.World.CurrentMap.Width, GameLoop.World.CurrentMap.Height, Global.FontDefault, new Rectangle(0, 0, GameLoop.GameWidth, GameLoop.GameHeight), GameLoop.World.CurrentMap.Tiles);

       // Don't forget to add the EntityManager to the MapConsole
            // or we won't be able to keep track of our actors
            MapConsole.Children.Add(GameLoop.EntityManager);
        }

Add a new method called CreateMapWindow. Its purpose is to act as a window container for the MapConsole. Think about the possession hierarchy we’ll have now: UIManager (contains a) MapWindow (which contains a) MapConsole.

        // Creates a window that encloses a map console
        // of a specified height and width
        // and displays a centered window title
        // make sure it is added as a child of the UIManager
        // so it is updated and drawn
        public void CreateMapWindow(int width, int height, string title)
        {
            MapWindow = new Window(width, height);
            MapWindow.Dragable = true;

            //make console short enough to show the window title
            //and borders, and position it away from borders
            int mapConsoleWidth = width - 2;
            int mapConsoleHeight = height - 2;

            // Resize the Map Console's ViewPort to fit inside of the window's borders snugly
            MapConsole.ViewPort = new Rectangle(0, 0, mapConsoleWidth, mapConsoleHeight);

            //reposition the MapConsole so it doesnt overlap with the left/top window edges
            MapConsole.Position = new Point(1, 1);

            //close window button
            Button closeButton = new Button(3, 1);
            closeButton.Position = new Point(0, 0);
            closeButton.Text = "[X]";

            //Add the close button to the Window's list of UI elements
            MapWindow.Add(closeButton);

            // Centre the title text at the top of the window
            MapWindow.Title = title.Align(HorizontalAlignment.Center, mapConsoleWidth);

            //add the map viewer to the window
            MapWindow.Children.Add(MapConsole);

            // The MapWindow becomes a child console of the UIManager
            Children.Add(MapWindow);

// Without this, the window will never be visible on screen
            MapWindow.Show();
        }

In the above code, we are setting the new window’s Dragable property to true. This allows us to click’n’drag the window around by its titlebar. SadConsole does all of that work for us, thankfully!

We are resizing the MapConsole’s ViewPort to suit the size of the Window. If you fail to resize the ViewPort, you’re going to see the whole map spill over the boundaries of the window. (Go ahead, try it!) We then reposition the MapConsole inside of the window, to prevent the console from bleeding over the top-left edges of the window.

Adding UI elements like buttons is very straightforward. They require a width and height, and need to be positioned inside of the window’s coordinates. Setting a button’s text is done via the Button.Text property. Don’t forget to add the UI element to the Window’s list of children! This is done via the Window.Add method. Without this, your button (or label, or slider) will never appear.

Finally, we add MapConsole as a child of the MapWindow; the MapWindow is added as a child of the UIManager. And ta-da… to get the window to show up on the screen, we call MapWindow.Show!

A couple more minor edits before we can test out our windowing system. Let’s add an Init method to UIManager, which will eventually contain all of our console and window initialization:

        // Initializes all windows and consoles
        public void Init()
        {
            CreateConsoles();
            CreateMapWindow(GameLoop.GameWidth / 2, GameLoop.GameHeight / 2, "Game Map");
        }

And then we’ll call Init from the GameLoop. In GameLoop.Init, delete the UIManager.CreateConsoles(); line and replace it with:

UIManager.Init();

Run your project. You should have a draggable window containing your game map. When you walk your character around, it will stay in the right position relative to the map, even if you move the map window around.

Notice something awesome? Our ghosting @ bug mysteriously disappeared! All of that refactoring ensured that the UIManager and its children are in the right SadConsole Draw and Update processing order. Only you can prevent forest fires by keeping game logic and graphics out of the GameLoop!

SadConsole has some incredibly powerful UI creation abilities, and we’ll explore those in future tutorials.

Download the final source code for this tutorial here.

Published on November 15, 2018