Design Patterns and Video Games

OpenGL 2D Facade (19): Test content

Before I continue the facade, I want to create some game data to test it. As you may imagine, I use software design for that!

This post is part of the OpenGL 2D Facade series

Objective

The expected result of this post is a simple level editor where we can draw ground tiles. It is enough to check that this first implementation is working fine:

Main approach

Game state, game logic and UI

I propose to follow the following approach to design the game engine:

Main game design

It has some similarities with Model-View-Controler (MVC) approach. As a model, we find the game state, which contains all data required to represent the game at a given time (or epoch). In the MVC approach, the model contains data and logic. In the proposed approach, the logic is not in the model, and we mix it with the controller. As a result, the game logic contains all the procedures (actions) that update the game state. It also contains the conversion of commands into these actions. Finally, the user interface is very similar to the view in MVC, where we represent data and collect user input.

Concerning the interaction between game state, logic, and UI, the main difference lies in the passive nature of the game state. When the user acts, the UI produces a command. For instance, if he/she presses the right arrow key, the UI can create a command "move the character to the right". Then it sends this command to the game logic, which deduces the actions needed to modify the state. In the move command example, we update the character's xcoordinate. Besides, the game logic triggers the sending of events indicating state changes - it is not the state that transparently sends notifications. When the UI receives the event, it modifies its rendering data accordingly. For example, it adjusts the x coordinate of the character's sprite.

Motivation

This approach is dedicated to video games and to any application whose display requires similar constraints. Indeed, the user interface, on the one hand, and the game state and logic on the other, evolve in two "parallel" universes:

To manage these aspects, one must be able to operate the two entities independently. Communication should take place only briefly at key moments in the game cycle. Between these moments, everyone must evolve independently:

I don't pretend that this approach is the only solution to every conceivable problem. It is perfectly suited to a large number of cases, partially answers the problems raised by others, and is irrelevant in some cases.

In the following, I give a brief description of a first implementation of this model. However, it is not the main purpose of this series that focuses on GUI facade. It does not include all expected features, such as parallel processing. After this series, I think I'll create a sequel to the "Discover Python & Patterns" that better describes this kind of approach. If you can't wait, you can read the book "Learn Design Patterns with Game Progamming". The first chapters cover the simple case (one thread), and the last ones show how to run logic and UI in parallel, and consequently allow network gaming.

Design

Game state

As a first game state, I only consider regions with a ground:

Game state design

The Region class represents a single grid, with width per height cells. Each cell can have a value for the ground, for instance, grass, swamp or dead land. We store these values in a 3D Numpy array: cells[x, y, level]. x and y are the coordinates in the grid, and level the stack level. Right now, we only have one stack level, but later, we can add other ones, like water, trees, buildings, etc. Note that we use Numpy arrays rather than Python lists for performance reasons. These arrays can be less easy to manipulate (every cell must be a number, no objects), but they run incredibly faster than Python lists!

The State class contains all the game data. In this first design, we only consider regions indexed by a name. Furthermore, we use the Observer pattern to notify any registered listener when the state changes. In this case, we can notify when the current region has changed or if a cell was modified. Note that, in the proposed main approach, the state does not trigger these notifications. We expect that the game logic updates the state and then triggers the most relevant notifications.

Game logic

The game logic follows a basic Command pattern:

Game logic design

The Logic class stores the commands and executes them when needed. After this execution, we clear the command list.

The Command interface has only one method: execute(), which we call in the executeCommands() of the Logic class.

The two implementations of this interface handle the case of region creation and cell updating. In each case, the class stores all the required data and use it in the execute() method.

UI and Game modes

The GUI facade is the basis of the User Interface, and we need to extend it to each specific case. For instance, we have UI for the main menu, one when the character travels a region, one when we fight against monsters, etc. I propose to call these cases Game Modes, like the menu game mode, the play game mode, etc.

To design this, I propose to create a class hierarchy where each child class is one of these modes:

Game mode design

The GameMode abstract class is the base of the hierarchy. It contains a subset of methods of the Game Loop pattern. There is no rendering method because the GUI facade holds all the data it needs (no references to the game state) and continuously renders frames.

The EditGameMode is one implementation of the GameMode class. Its purpose is to edit the ground cells of a region. It has the following attributes:

Implementation of EditGameMode

The implementation of game state and logic is straightforward, so we focus on the EditGameMode class.

Constructor

The constructor creates attributes:

def __init__(self, gui: GUIFacade):
    self.__gui = gui
    self.__state = State()
    self.__state.addListener(self)
    self.__logic = Logic()

    self.__tileSize = 32  # type: int
    self.__viewX = 0  # type: int
    self.__viewY = 0  # type: int

    self.__groundLayer = None  # type: Union[None, GridLayer]
    self.__currentRegionName = None  # type: Union[None, str]
    self.__currentRegion = None  # type: Union[None, Region]

    self.__textLayer = gui.createTextLayer()
    style = TextStyle("assets/font/terminal-grotesque.ttf", self.__tileSize, (255, 255, 255))
    self.__textLayer.setStyle(style)
    self.__textLayer.setMaxCharacterCount(300)
    self.__textLayer.setText(
        0, 0,
        "<b>Left button:</b> Swamp\n"
        "<b>Right button:</b> Grass\n"
    )

Note that the EditGameMode class is a listener of the State class (line 4). It means that when something happens to the game state, this class can react accordingly. Since this is a UI class, most reactions are updates of the current display.

Initialization

We call the init() method every time we need to edit a new region. On the contrary to the constructor, we can call this method several times:

def init(self):
    self.__logic.addCommand(CreateRegionCommand(self.__state, 'test', 64, 48))

This method sends a new command that asks for creating a new region named "test". As for any command, it may not succeed, for instance, if the "test" region already exists. The current implementation does not handle errors: the program crashes when it happens.

Input handling

The handleInputs method of the EditGameMode class analyzes mouse and keyboard states:

def handleInputs(self) -> bool:
    # Mouse
    mouse = self.__gui.mouse
    if mouse.button1:
        x = (mouse.x + self.__viewX) // self.__tileSize
        y = (mouse.y + self.__viewY) // self.__tileSize
        self.__logic.addCommand(SetGroundValueCommand(self.__state, 'test', x, y, GROUND_SWAMP))
        return True
    if mouse.button3:
        x = (mouse.x + self.__viewX) // self.__tileSize
        y = (mouse.y + self.__viewY) // self.__tileSize
        self.__logic.addCommand(SetGroundValueCommand(self.__state, 'test', x, y, GROUND_GRASS))
        return True

    # Keyboard
    keyboard = self.__gui.keyboard
    shiftX = 0
    shiftY = 0
    if keyboard.isKeyPressed(Keyboard.K_LEFT):
        shiftX -= self.__tileSize
    if keyboard.isKeyPressed(Keyboard.K_RIGHT):
        shiftX += self.__tileSize
    if keyboard.isKeyPressed(Keyboard.K_UP):
        shiftY -= self.__tileSize
    if keyboard.isKeyPressed(Keyboard.K_DOWN):
        shiftY += self.__tileSize
    if shiftX != 0 or shiftY != 0:
        self.__viewX += shiftX
        self.__viewY += shiftY
        self.__gui.setTranslation(self.__viewX, self.__viewY)
        return True

    return False

In both cases, it uses the GUIFacade referenced by the gui attribute. It also returns True if it found an action to do. Otherwise, it returns False. It is an example of the Chain of Responsibility pattern.

If the player clicks the left mouse button, we add a new command that draws a swamp cell below the cursor (lines 4-8). In the case of the right button, we draw a grass cell (lines 9-13).

If the player presses any arrow (one or more), we update the region's current view (lines 16-31). Note that we don't use commands in this case since the view is specific to the UI and has nothing to do with the game state.

Game state update

The updateState() method asks for the execution of all scheduled commands:

def updateState(self):
    self.__logic.executeCommands()

In this simple example, these command mechanics can seem unnecessary. However, as the program will get more complex, it will simplify many implementations and save us precious time!

Current region changed

The main purpose of the currentRegionChanged() method is to update the ground layer:

def currentRegionChanged(self, state: State, regionName: str):
    # Remove previous layers
    self.__gui.removeLayer(self.__groundLayer)
    self.__groundLayer = None

    # Case where we remove the region
    if regionName is None:
        self.__currentRegionName = None
        self.__currentRegion = None
        return

    self.__currentRegionName = regionName
    region = state.getRegion(regionName)
    self.__currentRegion = region
    width = region.width
    height = region.height

    # Create the ground layer
    self.__groundLayer = self.__gui.createGridLayer()
    self.__gui.setLayerLevel(self.__groundLayer, 0)
    self.__groundLayer.setTileset("assets/level/grass.png")
    self.__groundLayer.setTileSize(self.__tileSize, self.__tileSize)
    tiles = np.empty([width, height, 2], dtype=np.int32)
    tiles[..., 0] = 6
    tiles[..., 1] = 7
    self.__groundLayer.setTiles(tiles)

Lines 3-4 removes any current ground layer.

Lines 7-10 sets the current region references to None if there is no more region to display. It also leaves the method, so we don't create any ground layer.

Lines 12-16 update the current region references and get the size of the region.

Lines 19-26 create and initialize the ground layer. Lines 23-26 build a Numpy array with the tiles' coordinates of each grid layer's cell. We select the (6, 7) coordinates to get a grass tile in each cell.

Region cell changed

The regionCellChanged() method updates a tile at coordinates (x,y) according to the corresponding cell in the region:

def regionCellChanged(self, state: State, regionName: str, x: int, y: int):
    if regionName != self.__currentRegionName:
        return

    assert self.__groundLayer is not None

    region = self.__currentRegion
    value = region.getGroundValue(x, y)
    if value == GROUND_GRASS:
        tileX = 6
        tileY = 7
    elif value == GROUND_SWAMP:
        tileX = 22
        tileY = 7
    else:
        tileX = 0
        tileY = 0
    self.__groundLayer.setTile(x, y, tileX, tileY)

This method aims to translate a game state value (integer) into rendering data (tile coordinate).

Main game loop

The run.py file contains the main game loop:

# Create window
gui = GUIFacadeFactory().createInstance("OpenGL")
gui.createWindow(
    "OpenGL 2D Facade - https://www.patternsgameprog.com/",
    1280, 768
)

# Create a play game mode
mainMode = EditGameMode(gui)

# Run game
gui.init()
mainMode.init()
while not gui.closingRequested:

    # Handle inputs
    gui.updateInputs()
    if not mainMode.handleInputs():
        # If the mode didn't handle inputs, run a default handling
        keyboard = gui.keyboard
        for keyEvent in keyboard.keyEvents:
            if keyEvent.type == KeyEvent.KEYDOWN:
                if keyEvent.key == Keyboard.K_ESCAPE:
                    gui.closingRequested = True
                    break
        keyboard.clearKeyEvents()

    # Update game state
    mainMode.updateState()

    # Render scene
    gui.render()

# Delete objects
gui.quit()

Lines 2-7 creates the GUI facade and a new window.

Line 10 creates the main game mode. We have only one game mode, but later it will be easy to create other ones and switch from one to another.

Lines 13-32 is the main game loop, with the Game Loop pattern's usual steps. Note line 18: it calls the handleInputs()method of the current game mode. If this method returns False, it means that the mode executed no actions. If so, we choose to run a default input handling, which ends the game if the player presses the escape key (lines 20-26).

Final program

Download code and assets

In the next post, I'll show how to compute tile borders automatically.