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:
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.
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
@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.
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.
We create a new set of methods in the GameMode
class dedicated to mouse events:
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.
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.
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)
In the next post, we add two more layers.