Design Patterns and Video Games

2D Strategy Game (9): Frames and Buttons

We add a palette frame with many buttons to select tiles we add to the world. This frame is like the previous layer components we created, except it does not cover the entire window area. As a result, we only render in its area and only react if the mouse is inside.

This post is part of the 2D Strategy Game series

Design

We introduce new features and classes to implement this frame (we don't show all methods and attributes). The main new feature is the ability for a component only to consider an area (not the entire window). Then each button reacts to clicks where we render it, and the palette frame contains a set of buttons:

Frame and Button UI components

Component

We first add a new attribute area in the Component class: it is a Pygame rectangle representing a UI component's area. We assume that a component only renders and reacts to mouse events for pixels inside this area. We can initialize the size of the area in the constructor:

def __init__(self, theme: Theme,
             size: Optional[Tuple[int, int]] = None):
    super().__init__()
    self.__theme = theme
    if size is None:
        self.__area = Rect(0, 0, theme.viewSize[0], theme.viewSize[1])
    else:
        self.__area = Rect(0, 0, size[0], size[1])

We also add many properties to get details about this area, for instance (NB: we don't show them in the diagram above):

@property
def area(self) -> Rect:
    return self.__area.copy()
@property
def topLeft(self) -> Tuple[int, int]:
    return int(self.__area.left), int(self.__area.top)

The area property returns a copy of the area attribute. Note well: we don't return the attribute but a copy! It is to ensure that no one can change the area without using a class method. Otherwise, a user of this class could type something like component.area.update(0, 0, 100, 100) and change the area manually. In some cases, it can lead to an unexpected result (like the composites we see after). Returning a copy, the user updates the copy, and the component attribute is left unchanged.
The topLeft property is one of the commodity properties and returns the coordinates of the top-left pixel. It returns a tuple, which is immutable in Python (no one can change its values).

The moveTo() and shiftBy() methods update the top-left corner of the component:

def moveTo(self, topLeft: Tuple[int, int]):
    self.__area.update(topLeft, self.size)
def shiftBy(self, shift: Tuple[int, int]):
    self.__area.move_ip(shift)

Composite component

We update the CompositeComponent class to handle child component areas. We first update the constructor, which transmits the area size to the parent/superclass constructor:

def __init__(self, theme: Theme,
             size: Optional[Tuple[int, int]] = None):
    super().__init__(theme, size)
    self.__components: List[Component] = []

When we ask to move the composite, for instance, using the shiftBy() method, we must also move the child components:

def shiftBy(self, shift: Tuple[int, int]):
    for component in self.__components:
        component.shiftBy(shift)
    super().shiftBy(shift)

We can implement the moveTo() method similarly. Another solution consists in calling the shiftBy() method. With this second approach, all the logic is in a single method. Consequently, if we have to update this logic, we only have one method to change:

def moveTo(self, topLeft: Tuple[int, int]):
    shift = (
        topLeft[0] - self.topLeft[0],
        topLeft[1] - self.topLeft[1]
    )
    self.shiftBy(shift)

We also add a commodity method pack() that computes the smallest area that contains all child components. We add a border defined in the theme:

def pack(self):
    minX, minY = 10000, 10000
    maxX, maxY = -10000, -10000
    for component in self.__components:
        x1, y1 = component.topLeft
        x2, y2 = component.bottomRight
        minX, minY = min(minX, x1), min(minY, y1)
        maxX, maxY = max(maxX, x2), max(maxY, y2)
    borderSize = self.theme.frameBorderSize
    super().moveTo((minX - borderSize, minY - borderSize))
    width = maxX - minX + 2 * borderSize
    height = maxY - minY + 2 * borderSize
    super().resize((width, height))

Lines 2-8 are a typical min/max search algorithm. First, we init the minimum x and y values with very small/large values (Lines 2-3). Then, we iterate through all components (line 4) and get the minimum (line 5) and maximum coordinates (line 6). Finally, we update each bound (lines 7-8). The syntax is highly compact, thanks to Python. If it is not clear, here is an equivalent to the update of the minimum x value minX = min(minX, x1):

if x1 < minX:
    minX = x1

We must also update the UI event handler method working with the mouse: in each case, we should only react if the mouse cursor is in the component area. Here is an example with a mouse button down:

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

Line 2 checks if the mouse cursor is in the component area, using the commodity method contains(). This method of the Component class returns True if pixel coordinates are in the component area:

# In Component class
def contains(self, pixel: Tuple[int, int]) -> bool:
    return self.__area.collidepoint(pixel) != 0

Frame components

We create a FrameComponent class as a base class for any frame. Its job is two-fold: render a frame and handle the mouse.

We use nine tiles to render the frame:

Frame tiles

We draw the corners tiles only once and repeat the others to fill the component area:

Frame tiles

The width and the height of the component may not divide the size of the tiles. Therefore, we render the right column and bottom row at the expected place in these cases, erasing tiles from the penultimate column or row. Implementation details are in the draw() method of the FrameComponent class (many for loops and blits, nothing advanced).

Frames also handle mouse events: we ensure that it captures all events inside its area. So, for instance, if the player clicks on a frame, the click never goes to the component behind, even if no frame children reacted. Here is an example with the mouse button down:

def mouseButtonDown(self, mouse: Mouse) -> bool:
    super().mouseButtonDown(mouse)
    return True

We call the super method (line 2), which goes through all child components (FrameComponent is a child of CompositeComponent). Then, whatever happens, we return True, so no one else can capture the event.

Note that we don't check if the mouse cursor is inside the frame. We assume that composite components handle that. In other words, the frame component is always inside another composite component. In this case, the frame is inside the game mode, a composite component. It always works as long as the top component is a composite and if component containers always transmit mouse events to their children if they contain the cursor. This approach saves us from handling the mouse cursor and reduces code length.

Buttons

Thanks to all that we did previously, creating buttons is straightforward:

class Button(Component, IUIEventHandler):
    def __init__(self, theme: Theme,
                 surface: Surface,
                 action: Callable[[Mouse], NoReturn]):
        self.__tile = surface
        self.__action = action
        super().__init__(theme, surface.get_size())
    def render(self, surface: Surface):
        surface.blit(self.__tile, self.topLeft)
    def mouseButtonDown(self, mouse: Mouse) -> bool:
        self.__action(mouse)
        return True

We need two main objects: a surface to draw the button and an action to perform when clicking the button. We assume that the button's size is that of the surface (line 7). Then, we render this surface in the render() method (line 9). An action is a callable object (a function or a lambda) with one argument (Mouse) and returns no value. The type is then: Callable[[Mouse], NoReturn]. We call this action when the user clicks the button (line 11).

We always return True for the mouse button down event (line 12) and do the same for all mouse events (not shown in the code above). It ensures that the button captures all mouse events in its area, and no component behind can get one of them.

Palette frame and anchors

We create buttons to add and remove tiles. Then, we add them to the palette frame (it is a composite component), move them correctly, and call the pack() method. After that, we have nothing else to do: each button calls its action if we click it, it moves if we move the palette frame, the composites handle the mouse cursor, etc. All the logic we previously implemented does most of the job; as a result, we can focus on the specifics.

Buttons creation: the createButton() private method creates a button for a tile (like CellValue.GROUND_EARTH) in a layer (like "ground"):

def __createButton(self, layerName: str, 
                   tileId: Union[str, int]) -> Button:
    def buttonAction(mouse: Mouse):
        if mouse.button1:
            self.notifyMainBrushSelected(layerName, tileId)
        elif mouse.button3:
            self.notifySecondaryBrushSelected(layerName, tileId)
    tileset = self.theme.getTileset(layerName)
    tile = tileset.getTile(tileId)
    return Button(self.theme, tile, buttonAction)

We get the tile surface from the theme (lines 8-9). Then, we build the button action with an inner function, buttonAction. It is a closure since it captures context variables self (the palette frame) and tileId. This function triggers new notifications depending on the mouse button.

Buttons layout: rather than defining the location of each button manually, we use anchors to place them. More specifically, we put the first button top-left, then anchor all the others. It is a common approach in UI design that saves a lot of time and leads to clear layouts:

Frame tiles

We add buttons to a column, and when it is complete, we move on to the next one. For instance, we place the second button (a sea tile) below the first one (a ground tile). More specifically, we anchor the "top" of the second button to the "bottom" of the first one. Then, since we limit to columns of 2 elements, we place the next button right to the first: we anchor the "left" of the third button to the "right" of the first one. We repeat this process for all tiles and all layers (see addTilesetButtons() in the attached code).

Anchors: we implement anchors in the Component class with the moveRelativeTo() method:

def moveRelativeTo(self,
                   anchor: str,
                   other: Optional[Component] = None,
                   otherAnchor: str = "center",
                   shiftX: Optional[int] = None,
                   shiftY: Optional[int] = None,
                   borderSize: Optional[int] = None):
    ...

The idea is the following: we want to attach the anchor of self to the otherAnchor of other. The other parameters shiftX, shiftY, and borderSize define the padding or margin (otherwise, there is a default one).

An anchor is one of the nine following locations of a component:

Frame anchors

Each anchor has a name, like "topLeft", "top", "center", etc.

Here are some examples of anchoring:

Anchor examples

The first one attaches "right" to "left", and the second "bottomRight" to "topLeft". The next ones are inner anchors: we assume that if anchors are the same, then we place one component inside the other. In the examples above, the third is "center" to "center", and the fourth is "bottomRight" to "bottomRight".

We use these anchors to layout the buttons (see addTilesetButtons() in the attached code), but also for the palette frame in the main component (the EditGameMode):

self.__paletteFrame = PaletteFrame(theme, world)
self.__paletteFrame.moveRelativeTo("bottom", self, "bottom")

You can go to the constructor of the EditGameMode class and try other anchors to see what happens (same anchors; otherwise, the palette will be outside the window). You can also add another palette frame and change the anchors:

self.__paletteFrame2 = PaletteFrame(theme, world)
self.__paletteFrame2.moveRelativeTo("bottom", self.__paletteFrame, "top")
self.__paletteFrame2.registerListener(self)
self.addComponent(self.__paletteFrame2)

Note that the buttons of this second palette frame also work: this is the magic of our design.

We don't detail the implementation of the moveRelativeTo() method of the Component class: it is quite tricky and not very interesting for the subject of this series. Moreover, note that it implements static anchors: if we move one component, it won't automatically move the anchored components. Adding this feature is a good exercise.

Other improvements

We also add a new event in the IComponentListener interface to notify when a brush changes. The EditGameMode class listens to these events and updates the current brushes.

We also add a findMouseFocus() method in the component class hierarchy: it searches for the current component with the mouse cursor. Then, we use the result to trigger the mouse enter and mouse leave events.

Final program

Download code and assets

In the next post, we show a method that improve tiles diversity.