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
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:
I propose to follow the following approach to design the game engine:
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.
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.
As a first game state, I only consider regions with a ground:
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].
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!
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.
The game logic follows a basic Command pattern:
Logic class stores the commands and executes them when needed. After this execution, we clear the command list.
Command interface has only one method:
execute(), which we call in the
executeCommands() of the
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
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:
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.
EditGameMode is one implementation of the
GameMode class. Its purpose is to edit the ground cells of a region. It has the following attributes:
logic: the game mode contains a game state that holds the region to edit and a game logic to update this game state.
currentRegion: this is the name and reference of the region we are editing. The game mode does not contain it; this is the property of the game state.
gui: a reference to the GUIFacade, we use it to create and update the rendering data.
textLayer: references to layers, so we can update or delete them.
viewY: pixel-based coordinates of the current view of the region.
tileSize: the pixel size of a tile; useful in many cases!
The implementation of game state and logic is straightforward, so we focus on the
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.
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.
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.
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!
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.
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).
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).
In the next post, I'll show how to compute tile borders automatically.