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
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:
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.
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
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.
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).
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 def theme(self): return self.__theme def processInput(self): raise NotImplementedError() def update(self): raise NotImplementedError() def render(self, surface: Surface): raise NotImplementedError()
ABC from the
abc package, it is an abstract base class, and we can not create objects of this class. The methods
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.
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:
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.
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)...
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.
We create a new set of methods in the
GameMode class dedicated to mouse events:
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
EditGameMode, process this event.
The two classes
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.
We consider new Pygame events in the
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, pygameButtons, pygameButtons) 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
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 (
False, line 16), we set
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
Finally, if the mouse cursor is outside the game mode rendering area and has the mouse focus (line 28), we set
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.
We handle the mouse button down event in the
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
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
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
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
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
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)
In the next post, we add two more layers.