© Jarred Capellman, Louis Salin 2020
J. Capellman, L. SalinMonoGame Masteryhttps://doi.org/10.1007/978-1-4842-6309-9_4

4. Planning Your Game Engine

Jarred Capellman1  and Louis Salin1
(1)
Cedar Park, TX, USA
 

Building upon the last chapter, in this chapter we will start architecting the game engine. For each chapter, we will implement another component until completion. Proper planning and architecting are crucial to creating a successful engine, game (or any program for that matter). This chapter will also go over a couple design patterns used in game engines in case you want to explore other patterns in your own projects.

In this chapter, you will learn
  • Game engine design patterns

  • Programming design patterns

  • State management

  • MonoGame architecture

Game Engine Design

Game engines are used everywhere. From simple indie games to AAA games with multimillion-dollar budgets, any time developers want to reuse common game code, an engine is created. The most sophisticated engines are highly complex pieces of code that take teams dozens (or more) of months and in some cases years to release. The Unreal Engine 3 has over two million lines of code for reference. On the other hand, we could design a very small and simple engine that simply draws game objects for us and capture player input without doing anything else. However, properly designing and architecting prior to writing any code is critical to ensuring its reusability. In this chapter, we will first dive into the major components of the game engine we will write using the MonoGame Framework. Figure 4-1 shows the overall architecture of the engine.
../images/485839_1_En_4_Chapter/485839_1_En_4_Fig1_HTML.jpg
Figure 4-1

Game engine design architecture

Player Input

Driving many (if not most) of the interaction of your game engine is the input. Depending on your target platform, this can include everything from a standard gamepad, keyboard, and mouse combination to touch or head tracking via a virtual reality headset. In Chapter 6 we will deep dive into integrating a generic interface to gracefully handle the various inputs that exist today for games and future proofing as much as possible.

Artificial Intelligence (AI)

Artificial intelligence has been a critical component of games for decades. One of the earliest examples being Space Invaders in the late 1970s. While primitive by today’s standards, Space Invaders offered the player a challenge against the computer-controlled players with two different enemy types. In today’s games, pathfinding and decision trees drive most games.

Event Triggers

At the heart of our engine and many others is an Event Trigger system . The idea behind this is to define a generic event such as a Player clicks the left mouse button. The actual game would then listen in on this event and perform one or more actions. The advantage here is to keep complexity to a minimum. A more traditional programming approach here would be to have specific calls to Render the Player, but then when the player clicked the right button have very similar code in another Render the Player method. This approach as you can see also creates DRY (don’t repeat yourself) violations. Later in this chapter, we will create the basis for our Event Trigger subsystem that we will build on in subsequent chapters.

Graphical Rendering

One of the most focused on components in a game engine is the graphics. Graphics rendering in most modern game engines includes sprites, 3D models, particles, and various texturing passes, to name a few. Fortunately, MonoGame provides easy-to-use interfaces, and for the purposes of this book, we will only focus on 2D rendering. Over the course of the remaining chapters, we will expand the rendering capabilities of our engine. In addition, we will specifically deep dive into adding a particle subsystem in Chapter 8.

Sound Rendering

Often overlooked, sound rendering is arguably equally critical to provide your audience with a high-quality auditory experience. Imagine watching your favorite action film without sound or music – it is missing half of the experience. In MonoGame, fortunately, it is very easy to add even a basic level to your game engine to provide both music and sound. Those that have done XNA development in the past, MonoGame has overhauled the interface and does not require the use of the XACT (Cross-Platform Audio Creation Tool). At a high level, MonoGame provides a simple Song class for as you probably inferred for music and SoundEffect for your sound effects. We will dive more into audio with MonoGame in Chapter 7 by adding music and sound effects to our engine.

Physics

Depending on the game, physics may actually be a more critical component than sound, input, or even graphics. There is a growing genre of games where the focus is on physics with relatively simple graphics such as Cut the Rope 2 or Angry Birds 2, where birds are slingshot toward precariously balanced structures that crumble to the ground as the bird crashes into its foundations. Much like the sound and graphic triggers, physics triggers may cause additional events such as the main character sprite colliding with an enemy, which in turn would cause an animation, health, and possibly the enemy to be destroyed.

State Management

State management is a common pattern to apply in games and MonoGame in particular due to the simple design it offers. The idea behind state management is that no matter how complex the video game, each screen, like the start menu that appears when the game is launched or the screen that displays the gameplay, can be broken into their own unique state.

Take, for instance, a traditional game’s different states:
  • Splash Screen

  • Main Menu

  • Gameplay

  • End of Level Summary

Each of these states often offers different input schemes, music, and sounds effects, not to mention different rendering of assets.

For example, a splash screen typically is comprised of
  • Full-screen scaled image or animated video

  • Music

  • Timed-based transitions or input-based progression

  • An input manager that waits on the user to start the game by pressing some key on their input device

On the other hand, the gameplay state will bring in physics, particles, and AI agents used to control enemies. It also has a much more complex input manager, capturing player movement and actions precisely. The gameplay state could also be responsible for synching up game state over a network if the player is playing with friends on the Internet. All this to say that breaking your game into groups of similar states will help as you begin to architect your game. Akin to designing around inheritance, properly grouping similar functionality and only extending when necessary will make the time to maintain your project and the development effort much smaller.

To further illustrate, let us look at a few of the MonoGame-powered Stardew Valley’s states in Figures 4-2, 4-3, and 4-4.
../images/485839_1_En_4_Chapter/485839_1_En_4_Fig2_HTML.jpg
Figure 4-2

Stardew Valley Main Menu

../images/485839_1_En_4_Chapter/485839_1_En_4_Fig3_HTML.jpg
Figure 4-3

Stardew Valley Menus

../images/485839_1_En_4_Chapter/485839_1_En_4_Fig4_HTML.jpg
Figure 4-4

Stardew Valley Gameplay

Starting with Figure 4-2, the Stardew Valley Main Menu state is comprised of
  • Layered animated sprites (some aligned)

  • Clickable buttons

  • Background music

While Figure 4-3’s Character Creation state is comprised of those same elements with the addition of input fields and more complex positioning of elements, allowing the player to create a new character with the desired appearance.

Finally, Figure 4-4 shows the main gameplay screen and has many components of the first two states but increases the complexity of the graphical rendering by adding game objects that can change over time and allowing the player to move around the game world.

Implementing the Architecture of the Engine

Now that each of the components in a modern game engine has been reviewed, it is now time for us to begin architecting our engine.

For those wanting to download the completed solution, see the chapter-4 folder for both the blank project in the start folder and the completed project in the end folder.

Creating the Project

Following the same steps we reviewed in Chapter 3, we will be creating the same project type for this chapter. Going forward, keep in mind this chapter’s project will be the basis for all remaining chapters of the book. Like in the previous chapter, create a new MonoGame Cross-Platform Desktop Application (OpenGL) project and rename the Game1.cs file and Game1 class to MainGame.cs and MainGame. After this, you should see a project like that shown in Figure 4-5.
../images/485839_1_En_4_Chapter/485839_1_En_4_Fig5_HTML.jpg
Figure 4-5

Visual Studio 2019 showing the blank Chapter 4 project

Creating the State Classes

As reviewed earlier in this chapter when we talked about state management, the main ideology in state management is an inheritance model to create a structure and cut down on the amount of code reuse for each state of your game. For the scope of this chapter, we will be creating the initial BaseGameState class followed by an empty SplashState and empty GameplayState class to be populated in the next chapters. Figure 4-6 illustrates the relationship between these states.
../images/485839_1_En_4_Chapter/485839_1_En_4_Fig6_HTML.jpg
Figure 4-6

Game States to be implemented

You will find in the following texts the starting code for our abstract BaseGameState class, which we will build upon throughout this book. Open up the chapter-4 end solution and look at the BaseGameState.cs class in StatesBaseBaseGameState.cs file:
using System;
using System.Collections.Generic;
using System.Linq;
using chapter_04.Objects.Base;
using Microsoft.Xna.Framework.Content;
using Microsoft.Xna.Framework.Graphics;
namespace chapter_04.States.Base
{
    public abstract class BaseGameState
    {
        private readonly List<BaseGameObject> _gameObjects = new List<BaseGameObject>();
        public abstract void LoadContent(ContentManager contentManager);
        public abstract void UnloadContent(ContentManager contentManager);
        public abstract void HandleInput();
        public event EventHandler<BaseGameState> OnStateSwitched;
        protected void SwitchState(BaseGameState gameState)
        {
            OnStateSwitched?.Invoke(this, gameState);
        }
        protected void AddGameObject(BaseGameObject gameObject)
        {
            _gameObjects.Add(gameObject);
        }
        public void Render(SpriteBatch spriteBatch)
        {
            foreach (var gameObject in _gameObjects.OrderBy(a => a.zIndex))
            {
                gameObject.Render(spriteBatch);
            }
        }
    }
}

Let’s start with the abstract method declarations of LoadContent and UnloadContent. These methods will provide an interface for, as you probably guessed, the loading and unloading of content. MonoGame uses the ContentManager class object to provide an easy-to-use interface to load content at runtime. We will cover this in detail in the next chapter when diving into asset management. For now, keep in mind that these methods will handle the state-specific unloading and loading of content.

The other abstract method, HandleInput, will provide a method for state-specific input handling. For this chapter, we will keep our implementations simple. In Chapter 6, as mentioned earlier, we will deep dive into abstracting the input handling.

The OnStateSwitched event and the SwitchState method provide both the method to switch the state from another state and the event for the main class to listen for. Any state class implementing this BaseGameState class will be able to call the SwitchState method and pass in the new state we wish to switch to. For example, pressing the Enter key in the SplashScreen state will call SwitchState and specify that we want to now use the Gameplay state. The Switch State method triggers an event that our MainGame class will respond to by unloading the current state and then loading the new state. At the next game loop iteration, the new state’s Update and Draw methods will start being called.

The AddGameObject method is the state method to add objects to the List collection of BaseGameObjects, which is used to keep track of game objects we want to draw on the screen. In future chapters, we will be using this method to add sprites, static images, and other objects to this list.

Lastly, the Render method provides a single method to iterate through all the game objects we want to render on the screen. This method is called from the main Draw method in the MainGame class. It takes all the game objects in other _gameObjects list and orders them by zIndex before drawing them. A zIndex is a technique to order game objects from farthest to closest. When MonoGame draws things to the screen, every drawn object will overwrite objects that were drawn before it. While this is desirable in the cases where objects closer to the viewer must hide objects farther away, the opposite is not something we want to do. For example, clouds should be drawn in front of the sun, not behind. So when we create game objects, we must draw them in order and that’s what we use the zIndex for. Why “z”? Because in 2D games we use an (X, Y) coordinate system where the X axis is horizontal and the Y axis is vertical. In 3D space, there is a third axis called Z, so we are essentially representing depth using a zIndex. Note that if every game object is at zIndex = 0, then our base state class cannot guarantee that everything will be drawn in the correct order.

Creating the Scaler and Window Management

Now that we have looked at our basic state management starting code, ahead of actually rendering anything on the screen, we need to handle scaling and supporting both windowed and full-screen modes.

Window Scaling

The idea behind window scaling is for your audience to enjoy the game as you intended regardless of the resolution. Take Figure 4-5. The window is currently set to a width of 640 and height of 480 pixels, while the texture has a width and height of 512 pixels. Given these dimensions and as shown in Figure 4-7, it consumes almost the entire screen.
../images/485839_1_En_4_Chapter/485839_1_En_4_Fig7_HTML.jpg
Figure 4-7

Unscaled 640x480 window with a 512x512 texture

Common in games for the last two decades is the choice of resolution, so let us retry this same rendering at a resolution of 1024x768. Figure 4-8 depicts this.
../images/485839_1_En_4_Chapter/485839_1_En_4_Fig8_HTML.jpg
Figure 4-8

Unscaled 1024x768 window with a 512x512 texture

As clearly shown, the visual experience for the higher resolution consumer of your game is significantly different.

Fortunately, MonoGame offers a very easy way to ensure this experience inconsistency is resolved. The approach assumes you design around a target resolution such as 1080p (1920x1080) if you are targeting PC or home consoles. Once the resolution has been decided, all of your assets should be produced with this resolution in mind. Images such as splash or background images should be this resolution or higher. Asset creation and management will be covered in more detail in the next chapter; however, keeping with this simple rule will help you as you start making your content.

After the target resolution has been decided, we will add a simple scale for both width and height relative to the target resolution. For instance, in the two examples, let us use 640x480 as the target resolution and keep the user resolution set to 1024x768. After implementing our scaler, see Figure 4-9. Notice outside of being larger (as expected), the experience is identical to the 640x480 screenshot in Figure 4-9.
../images/485839_1_En_4_Chapter/485839_1_En_4_Fig9_HTML.jpg
Figure 4-9

Scaled 1024x768 window with a 512x512 texture

Now let us dive into the code that drove this change. First, we need to define some new variables in our MainGame class:
private RenderTarget2D _renderTarget;
private Rectangle _renderScaleRectangle;
private const int DESIGNED_RESOLUTION_WIDTH = 640;
private const int DESIGNED_RESOLUTION_HEIGHT = 480;
private const float DESIGNED_RESOLUTION_ASPECT_RATIO = DESIGNED_RESOLUTION_WIDTH / (float)DESIGNED_RESOLUTION_HEIGHT;

The RenderTarget2D will hold the designed resolution target, while the _renderScaleRectangle variable will hold the scale rectangle. The DESIGNED* variables hold the designed for resolution; feel free to experiment with these values after adding this code.

After defining the new variables, we will need to initialize the RenderTarget and Rectangle variables to be used in our render loop in the Initialize method we had previously defined. In addition, we need to define a new method to create the rectangle in the following code:
protected override void Initialize()
{
    _renderTarget = new RenderTarget2D(graphics.GraphicsDevice, DESIGNED_RESOLUTION_WIDTH, DESIGNED_RESOLUTION_HEIGHT, false,
        SurfaceFormat.Color, DepthFormat.None, 0, RenderTargetUsage.DiscardContents);
    _renderScaleRectangle = GetScaleRectangle();
    base.Initialize();
}
private Rectangle GetScaleRectangle()
{
    var variance = 0.5;
    var actualAspectRatio = Window.ClientBounds.Width / (float)Window.ClientBounds.Height;
    Rectangle scaleRectangle;
    if (actualAspectRatio <= DESIGNED_RESOLUTION_ASPECT_RATIO)
    {
        var presentHeight = (int)(Window.ClientBounds.Width / DESIGNED_RESOLUTION_ASPECT_RATIO + variance);
        var barHeight = (Window.ClientBounds.Height - presentHeight) / 2;
        scaleRectangle = new Rectangle(0, barHeight, Window.ClientBounds.Width, presentHeight);
    }
    else
    {
        var presentWidth = (int)(Window.ClientBounds.Height * DESIGNED_RESOLUTION_ASPECT_RATIO + variance);
        var barWidth = (Window.ClientBounds.Width - presentWidth) / 2;
        scaleRectangle = new Rectangle(barWidth, 0, presentWidth, Window.ClientBounds.Height);
    }
    return scaleRectangle;
}
The GetScaleRectangle provides black bars akin to the scalers on your television screen based on the actual resolution vs. the design resolution. If the image being rendered to the screen is not the same size as the actual screen, the television will add black bars either horizontally or vertically to fill in the missing space. This method starts by calculating the ratio between the game window’s width and height. If that ratio is lower than the designed aspect ratio, which is our desired ratio, then we need to add black bars at the top and bottom of the screen to compensate. To do so, we create a scale rectangle that goes from the (0, barHeight) coordinate and is as wide as the game window and as high as it needs to be so the whole rectangle fits onto the screen. Here, barHeight is half of the padding that is needed. Figure 4-10 shows our scale rectangle if it was displayed on the game window.
../images/485839_1_En_4_Chapter/485839_1_En_4_Fig10_HTML.jpg
Figure 4-10

Finding a scale rectangle that fits the game window

Lastly, we modified the Draw method once more to render to the render target and then render the back buffer like so:
protected override void Draw(GameTime gameTime)
{
    // Render to the Render Target
    GraphicsDevice.SetRenderTarget(_renderTarget);
    GraphicsDevice.Clear(Color.CornflowerBlue);
    spriteBatch.Begin();
    _currentGameState.Render(spriteBatch);
    spriteBatch.End();
    // Now render the scaled content
    graphics.GraphicsDevice.SetRenderTarget(null);
    graphics.GraphicsDevice.Clear(ClearOptions.Target, Color.Black, 1.0f, 0);
    spriteBatch.Begin(SpriteSortMode.Immediate, BlendState.Opaque);
    spriteBatch.Draw(_renderTarget, _renderScaleRectangle, Color.White);
    spriteBatch.End();
    base.Draw(gameTime);
}
A few things are happening here. First, we are setting a render target on our graphics device. This _renderTarget variable is created in the constructor like this:
_renderTarget = new RenderTarget2D(graphics.GraphicsDevice, DESIGNED_RESOLUTION_WIDTH,
    DESIGNED_RESOLUTION_HEIGHT, false,
    SurfaceFormat.Color, DepthFormat.None, 0,
    RenderTargetUsage.DiscardContents);

A render target is a graphical buffer used to draw things on until we are ready to send it to the screen. While we draw on the render target, nothing will happen on the screen until we decide to draw that render target. Looking at the parameters, it sets the desired game viewport resolution, the area we want to draw it. It also sets the mipmap flag to false, the background color to black (because SurfaceFormat.Color is equal to zero), and specifies that we are not using any depth stencil buffer and that our preferredMultiSampleCount is zero (this is used when doing antialiasing), and whatever we draw into our render target will not be preserved.

Then the graphics device is cleared with the blue cornflower color, which causes the screen to be painted with that same color. We are now ready for the current game state to do its thing and draw things! We are using a spriteBatch for this, which is created in the LoadContent method:
spriteBatch = new SpriteBatch(GraphicsDevice);

We briefly explained the sprite batch in Chapter 3. It is an abstraction that we will use to draw our game primitives to the screen. It is mostly used for our sprites, meaning our game textures, but it can also handle other 2D primitives like lines and rectangles. It is called a sprite batch because we will add many sprites and primitives into a single batch that will be sent to the graphics card in on single call by MonoGame. It is more efficient to build a single batch during the Draw phase of the game loop than multiple batches, although there are a few reasons why a game developer may want to build many batches in a single drawing phase. To create a new batch in our engine, we use the spriteBatch.Begin method. Then we call the Render method on the current game state and close out the sprite batch by calling spriteBatch.End.

Now that we have rendered a single frame to our render target, we are ready to draw it to the screen, which we do by setting the graphics devices’ render target to null. We start by clearing the screen to a black color; then, we perform one more sprite batch phase, where we draw the render target into the scale rectangle we calculated earlier. Because the screen is initially cleared black and the render target was cleared to the cornflower blue color, if the designed resolution and the game window resolutions do not match, we will see black bars on the sides. We then end the sprite batch.

Adding this support early on in our engine design helps begin testing the engine across multiple resolutions and form factors such as a laptop screen vs. desktop monitor. Now that we have our window scaling, let us add in full-screen support to our window.

Full-Screen Support

Fortunately, in MonoGame, adding support for full screen is extremely easy. Enabling full-screen support is just one line. The following code shows how to have full screen enabled by setting graphics.IsFullScreen to true:
public MainGame()
{
    graphics = new GraphicsDeviceManager(this);
    graphics.PreferredBackBufferWidth = 1024;
    graphics.PreferredBackBufferHeight = 768;
    graphics.IsFullScreen = true;
    Content.RootDirectory = "Content";
}

Event System

The last major development in this chapter is adding the initial work on the event system . The idea behind this pattern is to have a single call and object or class listening to that particular event will do what it is programmed. This pattern will allow us over the course of the book adding all of the events to make a complete game.

For the scope of this chapter, we will add a single event, one to trigger the game to quit. To keep things strongly typed, we will define an enumeration like so:
public enum Events
{
    GAME_QUIT
}
Then in BaseGameState class, we have added a new EventHandler and method:
public event EventHandler<Events> OnEventNotification;
protected void NotifyEvent(Events eventType, object argument = null)
{
    OnEventNotification?.Invoke(this, eventType);
    foreach (var gameObject in _gameObjects)
    {
        gameObject.OnNotify(eventType);
    }
}
The idea behind this is we can notify the MainGame, who is listening to event notifications already, as well as any GameObjects that exist within the scope of the current game state by calling the OnNotify method that they all inherit and can override from the BaseGameObject base class:
public virtual void OnNotify(Events eventType) { }
The MainGame class will need to hook into the OnEventNotification event. Since we have already defined the SwitchGameState method, we will just need to add the event and define the implementation like so:
private void SwitchGameState(BaseGameState gameState)
{
    _currentGameState?.UnloadContent(Content);
    _currentGameState = gameState;
    _currentGameState.LoadContent(Content);
    _currentGameState.OnStateSwitched += CurrentGameState_OnStateSwitched;
    _currentGameState.OnEventNotification += _currentGameState_OnEventNotification;
}
private void _currentGameState_OnEventNotification(object sender, Enum.Events e)
{
    switch (e)
    {
        case Events.GAME_QUIT:
            Exit();
            break;
    }
}

With most events not needing to notify the MainGame class, this will be one of the few if any events you will need to handle specifically.

The last change we need to do is handle pressing the Enter button on the GameplayState class to trigger this event. For this, we will use code that will be explained in Chapter 6 when we discuss the different ways to capture player input. In the meantime, the following code checks if a gamepad’s back button is pressed or if the keyboard’s Enter key is pressed, in which case it fires the GAME_QUIT event:
public override void HandleInput()
{
    if (GamePad.GetState(PlayerIndex.One).Buttons.Back == ButtonState.Pressed ||
        Keyboard.GetState().IsKeyDown(Keys.Enter))
    {
        NotifyEvent(Events.GAME_QUIT);
    }
}

Summary

In this chapter, you learned about game engine design and state management and implemented the initial architecture for the engine that will drive the project going forward.

In the next chapter, we will dive into the Asset Pipeline providing sprite loading to liven up our newly created engine.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
3.147.47.59