Design Patterns and Video Games

2D Strategy Game (20): Notifications and tooltips

We add tooltips to the game to help new players!

This post is part of the 2D Strategy Game series

The following video shows examples of tooltips:

The tooltip frame and UI components

The tooltips appear when the mouse hovers over a UI component, the content of which depends on it. For instance, when the mouse cursor is on a unit, the tooltip frame shows its life points. If a component has no tooltip data, we hide the tooltip frame. There are several approaches to implement this new feature.

The first solution is creating a tooltip frame for each component with tooltip data. Unfortunately, it uses many resources and does not ensure the tooltip is on top of all components.

Another solution considers a single tooltip frame that belongs to a parent component that always renders it on top of all others. The game mode component is the best candidate in the current UI design since it is the root component. Therefore, we must only ensure the tooltip is its last child to get it on top of all others.

Then, any component can have a tooltip: the last thing we need is a way to tell the game mode that the tooltip should appear (or disappear). We can follow the following approaches:

Notifications

We base this approach on the Event Queue pattern. We call it Notification to distinguish it from the UI event handling. However, one may find it called "event-something" in many contexts.

We create the following classes with two examples of notifications:

Notifications (Event Queue pattern)

Usage. Sending a notification is simple: instantiate the notification we want (with attached data), and call the send() method. For instance, for sending a request for a tooltip:

notification = ShowTooltipRequested("message", 50, mouse)
notification.send()

On the other side, if a class wishes to receive notifications, it first registers to them (usually in the constructor):

Notification.addHandlers({
    "ShowTooltipRequested": self.showTooltipRequested,
    "HideTooltipRequested": self.hideTooltipRequested,
})

In this example, we register to two notifications: "ShowTooltipRequested" and "HideTooltipRequested". In each case, we give the method to call. For instance, if someone sends a "ShowTooltipRequested" notification, we call the showTooltipRequested() method. It gets all the data of the notification as arguments:

def showTooltipRequested(self, message: str, maxWidth: int, mouse: Optional[Mouse]):
    ...

Notification. We define notifications as child classes of the Notification abstract class. They have a name representing their type: we always choose a name/type with the class name. For instance, the name/type of the ShowTooltipRequested class is "ShowTooltipRequested".

Then, each notification class stores values and implements the getArgs() method to return them. For instance, the ShowTooltipRequested class stores a message, a width, and mouse coordinates:

class ShowTooltipRequested(Notification):
    def __init__(self, message: str, maxWidth: int, mouse: Optional[Mouse]):
        super().__init__("ShowTooltipRequested")
        self.mouse = mouse
        self.message = message
        self.maxWidth = maxWidth

    def getArgs(self) -> Tuple:
        return self.message, self.maxWidth, self.mouse

In the Notification class, we define a static attribute manager that references the notification manager.

class Notification(ABC):
    manager = NotificationManager()
    ...

The manager attribute is unique: it is the same for all instances of Notification. Note that it is a design choice: we want a single manager for the whole application, so every notification goes to the same main queue. It is also handy because we don't have to have a reference to a manager to use it. For instance, to add handlers to the manager, we implement a class method addHandlers():

@classmethod
def addHandlers(cls, handlers: Dict[str, Callable]):
    cls.manager.addHandlers(handlers)

Note the cls attribute: it references the Notification class, not an instance. We can access any static attribute with cls, like manager, even if there are no instances of Notification.

We can call this class method using the class instead of an instance:

Notification.addHandlers(...)

We can proceed similarly for methods like notifyAll() and removeHandler().

We can use the manager attribute for usual methods as if it was a non-static attribute. For instance, the send() method use the manager to send the Notification instance (self):

def send(self):
    self.manager.send(self)

It leads to a compact syntax; for instance, HideTooltipRequested().send() creates and sends the notification.

Notification manager. The NotificationManager class stores notifications in the queue list and methods that handle them in the handlers dictionary. We record the methods per notification type: each item of handlers is the list of methods for a specific notification type.

This implementation takes place in two stages: it first keeps track of notifications and then sends them all. Note that it does not choose when to send the notifications: it is up to the class user. This design is attractive because we use it with game modes. For each game epoch, we update UI components, which can send notifications. The manager queues these notifications but doesn't send them yet. Once all components are processed, we send all notifications, which can change the UI. This design is not mandatory: we could send notifications immediately or add an option in the send() method to choose when to send.

The send() method of the NotificationManager class adds a notification to the queue. Note that it does not consider unhandled notifications:

def send(self, notification: Notification):
    name = notification.name
    if name not in self.__handlers:
        return
    self.__queue.append(notification)

The addHandlers() method of the NotificationManager class stores several methods that handle notifications. We store them per notification type (a string with the name of the notification):

def addHandlers(self, handlers: Dict[str, Callable]):
    for name, handler in handlers.items():
        assert hasattr(handler, "__self__"), f"'{name}' handler: only method are supported"
        if name not in self.__handlers:
            self.__handlers[name] = []
        queueHandlers = self.__handlers[name]
        queueHandlers.append(handler)

The notifyAll() method of the NotificationManager class sends all notifications:

def notifyAll(self):
    for notification in self.__queue:
        name = notification.name
        if name not in self.__handlers:
            continue
        for handler in self.__handlers[name]:
            try:
                args = notification.getArgs()
                handler(*args)
            except Exception as ex:
                logging.error(f"Error during notification '{name}':\n{ex}")
                traceback.print_exc()
    self.__queue.clear()

We iterate through all queued notifications (line 2) in the order they were added. Then, we get each notification name (line 3) and only consider the ones the manager handles (lines 4-5).

We iterate through all handlers that manage the current notification name/type (line 6). Note that the order of this iteration is that of the insertion of the handlers.

Lines 7-12 run the current handler. If there is an error, we log the exception message (line 11) and output the stack. This way, the game does not crash if there is a problem but does not warm the player. An improvement is to show a message in the game UI to tell that something has gone wrong.

To execute a handler, we first get the data associated with the notification (line 8). For instance, the "ShowTooltipRequested" notification provides a tuple with a message, a width, and mouse coordinates. Then, we call the handler, turning the tuple into arguments using a * symbol before the tuple (line 9).

Finally, we clear the notification queue (line 13).

Tooltips in components

Tooltip properties. For the tooltip implementation, we assume that any UI component can have one. As a result, we add two new attributes to the Component class: tooltipMessage with the tooltip message and tooltipMaxWidth to define the maximum width of the tooltip frame:

class Component(...):
    def __init__(self, ...)
        ...
        self.__tooltipMessage: Optional[str] = None
        self.__tooltipMaxWidth = 300

If tooltipMessage is None, we consider there is no tooltip.

Tooltip methods. We create new methods in the Component class to handle tooltips.

The setTooltip() method defines the content of the tooltip:

def setTootip(self, message: str, maxWidth: int = 150):
    self.__tooltipMessage = message
    self.__tooltipMaxWidth = maxWidth

This method does not show the tooltip: it defines its properties and tells that it should appear if the mouse is over the component.

The showTooltip() method shows the tooltip (if any):

def showTooltip(self, mouse: Optional[Mouse]):
    if self.__tooltipMessage is not None:
        ShowTooltipRequested(self.__tooltipMessage, self.__tooltipMaxWidth, mouse).send()

To be more accurate, it asks for a tooltip, given its properties (in the attributes) and a mouse location (in mouse argument). At this point, the component doesn't know if we handle tooltips, if one or several actors take them, etc. It is not its job, and it is much easier (and robust) that way!

The hideTooltip() method hides the tooltip:

def hideTooltip(self):
    if self.__tooltipMessage is not None:
        HideTooltipRequested().send()

Similarly to showTooltip(), it asks for it, and it is up to the ones reacting to the notification to handle that.

The disableTooltip() method is similar to hideTooltip(), except it clears the tooltip data of the component.

Mouse. Still in the Component class, we implement the mouseEnter() and mouseLeave() methods to show/hide the tooltip when the mouse enters or leaves the component. Thanks to the previous methods, their implementation is straightforward:

def mouseEnter(self, mouse: Mouse) -> bool:
    self.showTooltip(mouse)
    return False

def mouseLeave(self) -> bool:
    self.hideTooltip()
    return False

The tooltip frame

We create a new TooltipFrame class for the tooltip frame, a child of the FrameComponent class. We create it using a message and a width:

def __init__(self, theme: Theme, message: str, maxWidth: int = 300):
    super().__init__(theme)
    self.__update(message, maxWidth)
    self.__show = True

The show attribute defines whether we should render the tooltip.

The update() private method sets the messageSurface attribute with the rendered text. It also computes and updates the size of the frame:

def __update(self, message: str, maxWidth: int):
    textRenderer = TextRenderer(self.theme, "small", maxWidth)
    self.__messageSurface = textRenderer.render(message)
    size = self.__messageSurface.get_size()
    borderSize = 2 * self.theme.framePadding
    size = vectorAddI(size, (borderSize, borderSize))
    self.resize(size)

Note that this implementation uses our text rendering system, based on an HTML-like syntax, with many features like text color, styles, icons, etc.

The render() method renders the precomputed surface if show is True:

def render(self, surface: Surface):
    if not self.__show:
        return
    super().render(surface)
    x, y = self.innerArea.topleft
    surface.blit(self.__messageSurface, (x, y))

The remaining setMessage(), show() and hide() methods update the related attributes.

Tooltip notifications

Finally, we need an actor that reacts to the tooltip notifications. We choose the DefaultGameMode class, e.g., the superclass of the city and world game modes.

We first add a tooltip frame (hidden by default) and register two methods in the constructor to handle showing and hiding a tooltip:

self._tooltipFrame = TooltipFrame(self.theme, "tooltip")
self._tooltipFrame.hide()
self.addComponent(self._tooltipFrame)
Notification.addHandlers({
    "ShowTooltipRequested": self.showTooltipRequested,
    "HideTooltipRequested": self.hideTooltipRequested,
})

Then, the showTooltipRequested() method updates the tooltip frame:

def showTooltipRequested(self, message: str, maxWidth: int, mouse: Optional[Mouse]):
    self._tooltipFrame.setMessage(message, maxWidth)
    if mouse is not None:
        pixel = self.__computeTooltipCoords(mouse)
        self._tooltipFrame.moveTo(pixel)
    self.moveFront(self._tooltipFrame)
    self._tooltipFrame.show()

The nofication manager calls this method when someone creates and send a ShowTooltipRequested, like the showTooltip() method of the Component class.

We set the tooltip properties (line 2) and move the tooltip frame near the mouse cursor if its coordinates are provided (lines 3-5). The computeTooltipCoords() private method computes screen coordinates so that the tooltip frame is always inside the screen.

We ensure that the tooltip frame is on top of all other child components (line 6). The moveFront() method of the CompositeComponent class puts a frame to the last position in its component array.

Finally, we show the tooltip (line 7).

The hideTooltipRequested() calls the hide() method of the tooltip frame.

Final program

Download code and assets

In the next post, we add buildings.