Design Patterns and Video Games

2D Strategy Game (8): Composite and Code generation

We render the world with several layer UI components, from the background to the foreground. The game mode handles this combination in the previous program, whereas it is not its primary job. So, we propose to move world UI into a new class. Furthermore, we use the Composite pattern to generalize the combination of UI components. It will also allow us to build new complex components later. The splitting must also handle inputs: we use the Chain of Responsibility pattern to walk through the child components and find the one that can react. We also use the power of Python to save development time with runtime code generation.

This post is part of the 2D Strategy Game series

Composite UI components

We use the Composite pattern to generalize the combination of UI components:

Composite UI components

Component abstract class: it contains a theme (colors, fonts, etc.) and tree methods:

LayerComponent class: it is as before, except it implements Component.

CompositeComponent class: it contains a list of child components. We can add a new one with the addComponent() method. The cache argument is a convenience that adds a cache on top of the component:

def addComponent(self, component: Component, cache: bool = False):
    if cache:
        component = CacheComponent(component)
    self.__components.append(component)

We remove all components with the deleteComponents() method. We must ensure that all components deleted their data before we remove them:

def deleteComponents(self):
    for component in self.__components:
        component.dispose()
    self.__components.clear()

The clear() method of Python lists removes all elements in the list.

All the remaining methods call the corresponding method in components. For instance, for the render() method:

def render(self, surface: Surface):
    for component in self.__components:
        component.render(surface)

WorldComponent class: it implements CompositeComposite, creates the layer components, and adds them to the composite list:

class WorldComponent(CompositeComponent):
    def __init__(self, theme: Theme, world: World):
        super().__init__(theme)
        for name in world.layerNames:
            layer = LayerComponent(theme, world, name)
            self.addComponent(layer, cache=True)

There is no more to do to render the world. However, user input is more complicated because not all components capture events.

Input handling

Only the first component under the mouse cursor should catch the event when the player clicks on the screen. We can solve this problem with the Chain of Responsibility pattern: it walks through the components tree and stops when one claims the event.

We implement this pattern with an interface IUIEventHandler that contains all the input events (like a mouse click):

User interface event handling

Each method of the IUIEventHandler interface returns a boolean: a True value means that the component handles the event, False otherwise.

CompositeComponent class: it implements the interface. In each event case, it loops through all components and calls the same method:

def mouseButtonDown(self, mouse: Mouse) -> bool:
    for component in reversed(self.__components):
        if isinstance(component, IUIEventHandler):
            if component.mouseButtonDown(mouse):
                return True
    return False

The reversed() built-in function returns an iterator that reads the list from the last to the first item. Since we draw from the first to the last, components in the foreground are the last ones in the list.

Line 3 checks if the component implements the event interface: not all components react to user input.

Line 4 calls the same method in the component, which returns True if it handles the event. In this case, we return True (Line 5), meaning that the composite component takes the event.

Finally, line 5 returns False: no one captures the event, and the tree parsing can continue.

WorldComponent class: it implements the CompositeComponent class as in the previous section and some of the event methods. These last methods are similar to those in the EditGameMode class: we move the world cell mouse handling from the game mode to the world component.

Game modes: the GameMode class is now a child class of CompositeComponent. This way, we inherit all the features of the composite: each game mode can contain several components, and we automatically transfer events to child components.

A game mode can still capture an event by implementing the corresponding methods. For instance, in the EditGameMode class, we implement keyDown() to set the current brush:

def keyDown(self, key: int):
    if key == pygame.K_F1:
        self.__brushLayer = "ground"
        return True
    elif key == pygame.K_F2:
        self.__brushLayer = "impassable"
        return True
    elif key == pygame.K_F3:
        self.__brushLayer = "objects"
        return True
    else:
        return super().keyDown(key)

Note the last two lines: we call the super method if the key is not F1, F2, or F3. As a result, the EditGameMode captures these three keys and lets its children handle the others.

UserInterface class: this class still parses the Pygame events and reacts to mouse and key inputs. Contrary to the previous implementation, we don't directly call game mode event methods, but one of the handleXXX() methods. For instance, for the pygame.KEYDOWN event, we call the handleKeyDown() method:

def handleKeyDown(self, key: int) -> bool:
    for handler in self.getEventHandlers():
        if handler.keyDown(key):
            return True
    return False

We parse a list of event handlers, and if one returns True, we stop the loop. The getEventHandlers() returns the list, and we can implement it in several ways. For now, we return a list with the current game mode, if any:

def getEventHandlers(self) -> List[IUIEventHandler]:
    return [mode for mode in [
        self.__gameMode
    ] if mode is not None]

If this pythonic syntax is not clear, the following code does the same:

modes = [self.__gameMode]
handlers = []
for mode in modes:
    if mode is not None:
        handlers.append(mode)
return handlers

World component events

We moved the cell click handling in the WorldComponent class with the previous improvements. However, we also moved the cell changes based on mouse clicks. This last task is not the job of the world component: it renders the world and converts mouse coordinates into cell coordinates.
The EditGameMode class is still the one that should handle what to do when the user clicks a world cell.

To satisfy these constraints, we use the Observer pattern as we did before, where each UI component can emit a signal:

Component listener

We use the Listenable generic class we previously coded: it brings all the elements we need to implement an Observer given an interface. In this case, the IComponentListener interface contains all the events. Right now, we only consider clicking and entering a cell (worldCellClicked() and worldCellEntered() methods).

Since WorldComponent is a component, any registered listener can react to its notifications. For instance, EditGameMode listens to WorldComponent, and when this later one notifies a cell clicking, we call the worldCellClicked(), and we update the cell. Compared to the previous implementation, the main change i, the edit game mode that directly receives the cell coordinates and doesn't handle the mouse dragging.

From mouse click to cell update

Here are the different steps that occur when the user clicks a world cell:

  1. UserInterface parses the Pygame events and finds a pygame.MOUSEBUTTONDOWN, it calls its handleMouseButtonDown() method;
  2. handleMouseButtonDown() of UserInterface iterates through all the event handlers (only EditGameMode in this example), and calls their mouseButtonDown() method;
  3. mouseButtonDown() of EditGameMode (implemented in CompositeComponent) iterates through all its child components (only WorldComponent in this example), and calls their mouseButtonDown();
  4. mouseButtonDown() of WorldComponent converts the mouse pixel coordinates into cell coordinates. If these coordinates are in the world, it notifies its listeners with notifyWorldCellClicked() (implemented in Component);
  5. notifyWorldCellClicked() in Component iterates through all the listeners (only EditGameMode in this example), and calls their worldCellClicked() method;
  6. worldCellClicked of EditGameMode, depending on the current brush, cell coordinates, and mouse button, creates a command and submit it to the game logic. For this example, let's say it is a SetGroundValueCommand;
  7. executeCommands() of Logic iterates through all commands and calls their check() methods. For this example, we assume that check() returns True and logic calls the execute() method of SetGroundValueCommand;
  8. execute() of SetGroundValueCommand updates the world cell and notifies this change calling notifyCellChanged();
  9. notifyCellChanged() of Layer iterates through all its listeners (only CacheComponent for ground layer component in this example), and calls its cellChanged() method;
  10. cellChanged() of CacheComponent calls the cellChanged() of LayerComponent which sets its needRefresh attribute to True;
  11. render() of CacheComponent sees that the ground layer component needs a repaint, and calls its render() method: we finally update the cell on screen!

All these steps could seem incredibly complex for a task as simple as updating a world cell. It is true because the current game is simple!
Later, this scheme will ease many interaction cases. We will see them in the following posts, where features as complex as implementing a minimap will be pretty straightforward!

Runtime code generation

The previous approach works fine, but we have to code many methods which are simple to implement. We can let Python generate these methods and save our coding time. For instance, we can generate all the component event methods (like worldCellClicked()) in the CompositeComponent class:

import inspect
listenerMethods = inspect.getmembers(
    IComponentListener, 
    predicate=inspect.isfunction
)
for name, method in listenerMethods:
    if name.startswith("__"):
        continue
    signature = inspect.signature(method)
    functionArguments = ", ".join(signature.parameters)
    parameters = islice(signature.parameters, 1, None)
    methodArguments = ", ".join(parameters)
    source = f'''
def CompositeComponent_{name}({functionArguments}):
    components = self._CompositeComponent__components
    for component in reversed(components):
        if isinstance(component, IComponentListener):
            component.{name}({methodArguments})
    '''
    exec(source)
    fullName = f'CompositeComponent_{name]}'
    function = globals()[fullName]
    setattr(CompositeComponent, name, function)

We first collect all the methods of the IComponentListener interface. We can get them using the built-in inspect package (line 1). The getmembers() function returns the members of a class or module (lines 2-5). We filter these members with the predicate argument: we only want functions.
Then, we iterate through these methods (line 6) and only consider non-private methods (lines 7-8).

Then, we aim to create a function for each event that iterates through all the child components and call the same event method. For instance, for worldCellClicked(), we want to generate the following function:

def CompositeComponent_worldCellClicked(self, cell, mouse):
    components = self._CompositeComponent__components
    for component in reversed(components):
        if isinstance(component, IComponentListener):
            component.worldCellClicked(cell, mouse)

Let's first remind that methods are particular cases of functions: the main difference is that we can call a method from an object without using the first argument. So, for instance, in the expression component.worldCellClicked(cell, mouse), we give two arguments, ignoring self. However, when defining a method, we must include the first self argument.

As a result, we need two argument lists: the first includes self (line 10), and the second does not (line 12). These expressions use the string class's join() method: it concatenates strings in a list and adds the string instance in between. For example, "+".join(["a","b","c"]) returns "a+b+c". The islice(list, first, last) from the itertools built-in package returns list items from first to last index. In our case, we get items from the second one (index 1) to the last (None).

Using the method name and these two arguments lists, we build a string with the function definition (lines 13-19). It is a multiline f-String: it starts with a f (line 13), and uses a triple quote to begin and end the string.

Line 20 executes the Python code in the string using the exec() built-in function. It adds the function to the global elements of the program. We can retrieve it using the global() built-in function (lines 21-22). Note that we must ensure that the function name is unique: otherwise, we could redefine an existing function. Finally, we use the built-in setattr() function to add the function to the CompositeComponent class. The result is as if we explicitly coded it in the python file.

We also automatically add the IUIEventHandler methods to the CompositeComponent class. We do the same for the CacheComponent class, which transfers all events to the component it caches. We could also use code generation for the handleXXXX() methods in the UserInterface class; in our program, we don't because we call these methods explicitly. If we generate them, Pycharm and mypy complain. This choice is not mandatory: code generation can be a better solution if you run out of time.

Final program

Download code and assets

In the next post, we implement UI frames and buttons.