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 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:
UserInterface
class broadcasts UI events (like a mouse click, render, etc.) in the UI component hierarchy. Anyone in this hierarchy can "capture" the event and stop the broadcast. For instance, if a component handles a mouse click, it returns True
, ensuring it is the only one to use the click (otherwise, components below it could also react).Listenable
class (Observer pattern): we connect each UI component with a tooltip to the game mode and add a tooltipRequested()
to the IComponentListener
interface. In a way, it is similar to the first approach: we have a reference to the root in every component. It is more interesting because we can have several root components that can handle tooltips. However, it shares the same flaws.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:
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).
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
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.
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.
In the next post, we add buildings.