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:
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.
The game logic follows a basic Command pattern:
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.
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:
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:
state
and logic
: the game mode contains a game state that holds the region to edit and a game logic to update this game state.currentRegionName
and 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.groundLayer
and textLayer
: references to layers, so we can update or delete them.viewX
and 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 EditGameMode
class.
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.
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.
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!
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.
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).
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).
In the next post, I'll show how to compute tile borders automatically.