Design Patterns and Video Games

2D Strategy Game (4): Mouse handling

I add mouse handling to edit the ground tiles. As usual, I propose a nice design to implement this!

This post is part of the 2D Strategy Game series

Theme and game mode

Separate low-level and high-level processing

In the current state of our game, the UserInterface class is the single class that handles what is related to the user interface. As usual, we want to split classes with too many features. Here is the list of what this class current does:

The last step runs two processing: world rendering and rescaling. World rendering is high-level processing that depends on context (playing, editing, menu, etc.). Rescaling is low-level processing that does not depend on context: whatever we render, we always have to rescale to the window size.
We can do the same reasoning for input processing and state updating; in the current game implementation, they do nothing, but later, we will face the same problem as the rendering.

We can solve these issues with the following design:

Split UserInterface

The UserInterface class still has the Game Loop patterns methods. It runs all the low-level processing and calls an implementation of the GameMode abstract class for the high-level processing. For instance, the render() method calls the game mode to get a rendering (world, menu, etc.) and then rescales to window size.

The EditGameMode class is one implementation of the GameMode abstract class. We create this class to edit the ground layer. Later, we will create other implementations with new features. They will work with no change to the UserInterface class.

The Theme class contains all data related to the user interface, like the size of tiles or tilesets. It is handier to refer to an instance of this class in all UI components. In the other case, we have to store and copy this data every time.

UserInterface class

The new implementations of the Game Loop methods call the similar method in the current game mode (if any):

def processInput(self):
    for event in pygame.event.get():
        if ...quit events...
    if self.__gameMode is not None:
        self.__gameMode.processInput()

def update(self):
    if self.__gameMode is not None:
        self.__gameMode.update()

def render(self):
    renderSurface = Surface((self.__renderWidth, self.__renderHeight))
    if self.__gameMode is not None:
        self.__gameMode.render(renderSurface)

    windowWidth, windowHeight = self.__window.get_size()
    ...code related to rescaling...
    self.__window.blit(rescaledSurface, (self.__rescaledX, self.__rescaledY))    

For instance, the render() method first asks the game mode to render the game (lines 12-14), then rescale and blit it to the screen (lines 16-18).

GameMode class

We use the ABC package to declare GameMode as an abstract base class:

from abc import ABC, abstractmethod
class GameMode(ABC):
    def __init__(self, theme: Theme):
        self.__theme = theme
    @property
    def theme(self):
        return self.__theme
    @abstractmethod
    def processInput(self):
        raise NotImplementedError()
    @abstractmethod
    def update(self):
        raise NotImplementedError()
    @abstractmethod
    def render(self, surface: Surface):
        raise NotImplementedError()

Since GameMode inherits ABC from the abc package, it is an abstract base class, and we can not create objects of this class. The methods processInput(), update() and render() are abstract methods: a child class must implement them, or it will be an abstract class. This mechanism ensures that we correctly implement all the required methods. Pycharm and mypy also warm you if the arguments are not the same.

The GameMode class also saves a reference to a Theme instance. We store it in a private member: the name of the attribute starts with two underscores. To access it from outside, we create a property:

    @property
    def theme(self):
        return self.__theme

A property is a method that simulates an attribute:

theme = gameMode.theme

With this code, it looks like theme is an attribute of GameMode. It calls the theme() method property. This example returns the value of the private attribute theme, but one can return the result of any processing.

EditGameMode class

The processInput() and update() methods of the EditGameMode class do nothing:

def processInput(self):
    pass

def update(self):
    pass

def render(self, surface: Surface):
    theme = self.theme
    tileWidth = theme.tileWidth
    tileHeight = theme.tileHeight
    tiles = theme.tiles
    tileset = theme.tileset
    for y in range(self.__world.height):
        for x in range(self.__world.width):
            ...render tile at (x,y)...

The render() method is as before, except that we get tile data from the theme. To access it, we use the theme property (line 8) defined in the base class.

Mouse Handling

Design

We create a new set of methods in the GameMode class dedicated to mouse events:

Mouse handling

The UserInteface class calls these methods from the processInput() method. For instance, it calls the mouseButtonDown() method when the player clicks a mouse button. Then, implementations of GameMode, like EditGameMode, process this event.
The two classes MouseButtons and MouseWheel store data about buttons and mouse wheel. We create these classes to reduce the number of arguments in mouse event methods. It also allows us to extend mouse event data easily. Of course, we could also collect all mouse data in a single class; it is a matter of preference.

UserInterface class

We consider new Pygame events in the processInput() method:

def processInput(self):
   for event in pygame.event.get():
        if ...quit events...
        elif event.type == pygame.ACTIVEEVENT:
            if event.state & pygame.APPFOCUSMOUSE == pygame.APPFOCUSMOUSE:
                self.__processMouseEvent(event)
        elif event.type == pygame.MOUSEBUTTONDOWN \
                or event.type == pygame.MOUSEBUTTONUP \
                or event.type == pygame.MOUSEWHEEL \
                or event.type == pygame.MOUSEMOTION:
            self.__processMouseEvent(event)

Pygame triggers the event ACTIVEEVENT (line 4) when a focus is gained or lost (mouse, keyboard, window, ...). We only consider the mouse case, when the APPFOCUSMOUSE bit is set in the state attribute of the event (line 5). If it is the case, we call a new private method processMouseEvent() (line 6).

For all the mouse-related events (lines 7-10), we also call the processMouseEvent() method. We implement it as follows:

def __processMouseEvent(self, event):
    if self.__gameMode is None:
        return
    if event.type == pygame.ACTIVEEVENT:
        if self.__mouseFocus:
            self.__mouseFocus = False
            self.__gameMode.mouseLeave()
        return
    mouseX, mouseY = pygame.mouse.get_pos()
    mouseX = int((mouseX - self.__rescaledX) / self.__rescaledScaleX)
    mouseY = int((mouseY - self.__rescaledY) / self.__rescaledScaleY)
    pygameButtons = pygame.mouse.get_pressed(num_buttons=3)
    buttons = MouseButtons(pygameButtons[0], pygameButtons[1], pygameButtons[2])
    if 0 <= mouseX < self.__renderWidth \
            and 0 <= mouseY < self.__renderHeight:
        if not self.__mouseFocus:
            self.__mouseFocus = True
            self.__gameMode.mouseEnter(mouseX, mouseY, buttons)
        if event.type == pygame.MOUSEBUTTONDOWN:
            self.__gameMode.mouseButtonDown(mouseX, mouseY, buttons)
        elif event.type == pygame.MOUSEBUTTONUP:
            self.__gameMode.mouseButtonUp(mouseX, mouseY, buttons)
        elif event.type == pygame.MOUSEWHEEL:
            wheel = MouseWheel(event.x, event.y, event.flipped, event.which)
            self.__gameMode.mouseWheel(mouseX, mouseY, buttons, wheel)
        elif event.type == pygame.MOUSEMOTION:
            self.__gameMode.mouseMove(mouseX, mouseY, buttons)
    elif self.__mouseFocus:
        self.__mouseFocus = False
        self.__gameMode.mouseLeave()

Lines 2-3 check that there is a game mode; there is no event to notify if it is not the case.

Lines 4-8 handle the case when the mouse is leaving the window. If so, we set a new private attribute mouseFocus to False.

Lines 9-13 build the mouse data. Lines 10-11 compute the mouse coordinates before the rescaling, using the shift rescaledX,rescaledY and scale factors rescaledScaleX,rescaledScaleY. We save these values during the rescaling in new private attributes. Thanks to this computation, the game mode always receives coordinates in the same rendering resolution and don't have to worry about the rescaling.

If the mouse cursor is inside the rendered part (e.g. not the black borders), we consider that the game mode has the mouse focus (lines 14-15). If the game mode has not the focus (mouseFocus is False, line 16), we set mouseFocus to True and notify the game mode that the cursor enters its area (lines 17-18). Then, depending on the Pygame event type, we call the corresponding method in the GameMode class.

Finally, if the mouse cursor is outside the game mode rendering area and has the mouse focus (line 28), we set mouseFocus to True and notify the game mode that the cursor leaves its area (lines 29-30).

I hope that this example shows how much complexity we can get from low-level mouse handling. We let UserInterface manage all this complexity once for all. Then, we can work more efficiently on high-level problems in our game modes.

EditGameMode class

We handle the mouse button down event in the mouseButtonDown() method:

def mouseButtonDown(self, mouseX: int, mouseY: int, buttons: MouseButtons):
    coords = self.__computeCellCoordintates(mouseX, mouseY)
    if coords is None:
        return
    cellX, cellY = coords
    self.__mouseButtonDown = True
    self.__updateCell(cellX, cellY, buttons)

This method uses a new private method computeCellCoordintates() (line 2), which converts the mouse coordinates (pixels) into world coordinates (cells). If the cursor is outside the world, it returns None and we leave the method (lines 3-4). If not, we change the cell with a new private method updateCell() (line 7). We also set a new private attribute mouseButtonDown to True (line 6); we use it to remember that the user clicked the mouse inside the world.

We run a similar processing in the mouseMove() method, which UserInterface calls when the player moves the mouse:

def mouseMove(self, mouseX: int, mouseY: int, buttons: MouseButtons):
    if not self.__mouseButtonDown:
        return
    coords = self.__computeCellCoordintates(mouseX, mouseY)
    if coords is None:
        return
    cellX, cellY = coords
    self.__updateCell(cellX, cellY, buttons)

We ignore this call if the mouseButtonDown is False (lines 2-3), meaning that the player clicked outside the world and then moved inside it. The rest of the method updates the cell if the mouse is inside the world.

We are also implementing three other mouse events, in which cases we set mouseButtonDown to False. It is to ignore all clicks and move from outside the world:

def mouseButtonUp(self, mouseX: int, mouseY: int, buttons: MouseButtons):
    self.__mouseButtonDown = False
def mouseEnter(self, mouseX: int, mouseY: int, buttons: MouseButtons):
    self.__mouseButtonDown = False
def mouseLeave(self):
    self.__mouseButtonDown = False

The computeCellCoordintates() private method converts from pixel to cell coordinates:

def __computeCellCoordintates(self, mouseX: int, mouseY: int) -> Optional[Tuple[int, int]]:
    cellX = mouseX // self.theme.tileWidth
    cellY = mouseY // self.theme.tileHeight
    if not (0 <= cellX < self.__world.width) \
    or not (0 <= cellY < self.__world.height):
        return None
    return cellX, cellY

This method returns an Optional[Tuple[int, int]]. It means that the return value can be either a None or a tuple of two integers (line 1). Note the use of the integer division operator // to divide pixel coordinates by the size of tiles (lines 2-3). In lines 4-6, we got another example of chained comparison: if the cell coordinates are not inside the world size, we return None.

The updateCell() private method changes the world value at cellX,cellY depending on the mouse button (button1 is left, button3 is right):

def __updateCell(self, cellX: int, cellY: int, buttons: MouseButtons):
    if buttons.button1:
        self.__world.setValue(cellX, cellY, LAYER_GROUND_EARTH)
    elif buttons.button3:
        self.__world.setValue(cellX, cellY, LAYER_GROUND_SEA)

Final program

Download code and assets

In the next post, we add two more layers.