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
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:
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, theme.viewSize) else: self.__area = Rect(0, 0, size, size)
We also add many properties to get details about this area, for instance (NB: we don't show them in the diagram above):
def area(self) -> Rect: return self.__area.copy() def topLeft(self) -> Tuple[int, int]: return int(self.__area.left), int(self.__area.top)
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.
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).
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)
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 - self.topLeft, topLeft - self.topLeft ) 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
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:
We draw the corners tiles only once and repeat the others to fill the component area:
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.
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.
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:
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
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
self to the
other. The other parameters
borderSize define the padding or margin (otherwise, there is a default one).
An anchor is one of the nine following locations of a component:
Each anchor has a name, like "topLeft", "top", "center", etc.
Here are some examples of anchoring:
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
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.
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.
In the next post, we show a method that improve tiles diversity.