Creating a Debugging Interface in Godot (Part 2)

Welcome to Part 2 of my tutorial for creating a debugging interface in Godot! In Part 1, we created the base for our debugging system. If you haven’t read that part, you should do so now, because the rest of the tutorial series will be building atop it. Alternatively, if you just want the code from the end of Part 1, you can check out the tutorial-part-1 branch in the Github repo.

At this point, we have the base of a debugging system, but that’s all it is: a base. We need to add things to it that will render the debugging information we want to show, as well as an API to DebugLayer that is responsible for communicating this information.

We’ll do this through “debug widgets”. What’s a debug widget? It’s a self-contained node that accepts a set of data, then displays it in a way specific to that individual widget. We’ll make a base DebugWidget node, to provide common functionalities, then make other debug widgets extend that base that implement their custom functionalities on top of the base node.

Alright, enough high-level architecture talk. Let’s dive in and make these changes!

Creating the Base DebugWidget

To get started, we want a place to store our debug widgets. To that end, make a new directory in _debug, called widgets. In this new widgets directory, create a new script called DebugWidget.gd, extending MarginContainer.


# Base class for nodes that are meant to be used with the DebugLayer system.
class_name DebugWidget
extends MarginContainer

Note the custom class_name. This is important, because later on we’ll be using it to check whether a given node is a debug widget.

You may need to reload your Godot project to ensure that the custom class_name gets registered.

Next, we’re going to add something called “widget keywords”:


# Abstract method which must be overridden by the inheriting debug widget.
# Returns the list of widget keywords. Responses to multiple keywords should be provided in _callback.
func get_widget_keywords() -> Array:
  push_error("DebugWidget.get_widget_keywords(): No widget keywords have been defined. Did you override the base DebugWidget.get_widget_keywords() method?")
  return []

This function will be responsible for returning a debug widget’s widget keywords. What are widget keywords, though?

To give a brief explanation, widget keywords are the way we’re going to expose what functionalities this debug widget provides to the debugging system. When we want to send data to a widget, the debugging system will search through a list of stored widget keywords, and if it finds one matching the one we supply in the data-sending function, it will run a callback associated with that widget keyword.

If that doesn’t make much sense right now, don’t worry. As you implement the rest of the flow, it should become clearer what widget keywords do.

One thing to note about the code is that we’re requiring inheriting classes to override the method. This is essentially an implementation of the interface pattern (since GDScript doesn’t provide an official way to do interfaces).

Let’s add a couple more functions to DebugWidget.gd:


# Abstract method which must be overridden by the inheriting debug widget.
# Handles the widget's response when one of its keywords has been invoked.
func _callback(widget_keyword, data) -> void:
  push_error('DebugWidget._callback(): No callback has been defined. (' + widget_keyword + ', ' + data + ')')


# Called by DebugContainer when one of its widget keywords has been invoked.
func handle_callback(widget_keyword: String, data) -> void:
  _callback(widget_keyword, data)

handle_callback() is responsible for calling the _callback() function. Right now, that’s all it does. We’ll eventually also do some pre-callback validation in this function, but we won’t get into that just yet.

_callback() is another method that we explicitly want the inheriting class to extend. Essentially, this is what will be run whenever something uses one of the debug widget’s keywords. Nothing is happening there right now; all the action is going to be in the inheriting debug widgets.

That’s it for the base DebugWidget. Time to extend that base!

Creating the DebugTextList DebugWidget

Remember that DebugLabel that was discussed at the beginning of the article? Having a text label that you can update as needed is a useful thing for a debugging system to have. Why stop with a single label, though? Why not create a debug widget that is a list of labels, which you can update with multiple bits of data?

That’s the debug widget we’re going to create. I call it the DebugTextList.

I prefix debug widget nodes with Debug, to indicate that they are only meant to be used for debugging purposes. It also makes it easy to find them when searching for scenes to instance.

Create a directory in widgets called TextList, then create a DebugTextList scene (not script). If you’ve registered the DebugWidget class, you can extend the scene from that; otherwise, this is the point where you’ll need to reload the project in order to get access to that custom class.

Why create it as a scene, and not as another custom node? Really, it’s simply so that we can create the node tree for our debug widget using the editor’s graphical interface, making it simpler to understand. It’s possible to add the same child nodes through a script, and thereby make it possible to make the DebugTextList a custom node. For this tutorial, however, I’m going to keep using the scene-based way, for simplicity.

Alright, let’s get back on with the tutorial.

Add a VBoxContainer child node to the DebugTextList root node. Afterwards, attach a new script to the DebugTextList scene, naming it DebugTextList.gd, and have it extend DebugWidget. Replace the default script text with the following code:


const WIDGET_KEYWORDS = {
  'ADD_LABEL': 'add_label',
  'REMOVE_LABEL': 'remove_label'
}


onready var listNode = $VBoxContainer

listNode is a reference to the VBoxContainer. We also have defined a const, WIDGET_KEYWORDS, which will define the widget keywords this debug widget supports. Technically, you could just use the keyword’s strings where needed, rather than define a const, but using the const is easier, as you can see below.


# Handles the widget's response when one of its keywords has been invoked.
func _callback(widget_keyword: String, data) -> void:
  match widget_keyword:
    WIDGET_KEYWORDS.ADD_LABEL:
      add_label(data.name, str(data.value))
    WIDGET_KEYWORDS.REMOVE_LABEL:
      remove_label(data.name)
    _:
      push_error('DebugTextList._callback(): widget_keyword not found. (' + widget_keyword + '", "' + name + '", "' + str(WIDGET_KEYWORDS) + '")')


# Returns the list of widget keywords.
func get_widget_keywords() -> Array:
  return [
    WIDGET_KEYWORDS.ADD_LABEL,
    WIDGET_KEYWORDS.REMOVE_LABEL
  ]

Notice that we’re overriding both _callback() and get_widget_keywords(). The latter returns the two widget keywords we defined in the const, while the former performs a match check against the widget_keyword argument to see if it matches one of our two defined keywords, running a corresponding function if so. By using the const to define our widget keywords, we’ve made it easier to ensure that the same values get used in all the places needed in our code.

match is Godot’s version of implementing the switch/case pattern used in other languages (well, it’s slightly different, but most of the time you can treat it as such). You can read more about it here. The underscore in the match declaration represents the default case, or what happens if widget_keyword doesn’t match our widget keywords.

Let’s go ahead and add the two response functions now: add_label() and remove_label(). We’ll also add a helper function that is used by both, _find_child_by_name().


# Returns a child node named child_name, or null if no child by that name is found.
func _find_child_by_name(child_name: String) -> Node:
  for child in listNode.get_children():
    if 'name' in child and child.name == child_name:
      return child

  return null


# Adds a label to the list, or updates label text if label_name matches an existing label's name.
func add_label(label_name: String, text_content: String) -> void:
  var existingLabel = _find_child_by_name(label_name)
  if existingLabel:
    existingLabel.text = text_content
    return

  var labelNode = Label.new()
  labelNode.name = label_name
  labelNode.text = text_content
  listNode.add_child(labelNode)


func remove_label(label_name) -> void:
  var labelNode = _find_child_by_name(label_name)
  if labelNode:
    listNode.remove_child(labelNode)

_find_child_by_name() takes a given child_name, loops through the children of listNode to see if any share that name, and returns that child if so. Otherwise, it returns null.

add_label() uses that function to see if a label with that name already exists. If the label exists, then it is updated with text_content. If it doesn’t exist, then a new label is created, given the name label_name and text text_content, and added as a child of listNode.

remove_label() looks for an existing child label, and removes it if found.

With this code, we now have a brand-new debug widget to use for our debugging purposes. It’s not quite ready for use to use, yet. We’re going to have to make changes to DebugLayer in order to make use of these debug widgets.

Modifying DebugLayer

Back in Part 1 of this tutorial, we made the DebugLayer scene a global AutoLoad, to make it accessible from any part of our code. Now, we need to add an API to allow game code to send information through DebugLayer to the debug widgets it contains.

Let’s start by adding a dictionary for keywords that DebugLayer will be responsible for keeping track of.


# The list of widget keywords associated with the DebugLayer.
var _widget_keywords = {}

Next, we’ll add in the ability to “register” debug widgets to the DebugLayer.


func _ready():
  # ...existing code
  _register_debug_widgets(self)


# Go through all children of provided node and register any DebugWidgets found.
func _register_debug_widgets(node) -> void:
  for child in node.get_children():
    if child is DebugWidget:
      register_debug_widget(child)
    elif child.get_child_count() > 0:
      _register_debug_widgets(child)


# Register a single DebugWidget to the DebugLayer.
func register_debug_widget(widgetNode) -> void:
  for widget_keyword in widgetNode.get_widget_keywords():
    _add_widget_keyword(widget_keyword, widgetNode)

In our _ready() function, we’ll call _register_debug_widgets() on the DebugLayer root node. _register_debug_widgets() loops through the children of the passed-in node (which, during the ready function execution, is DebugLayer). If any children with the DebugWidget class are found, it’ll call register_debug_widget() to register it. Otherwise, if that child has children, then _register_debug_widgets() is called on that child, so that ultimately all the nodes in DebugLayer will be processed to ensure all debug widgets are found.

register_debug_widget(), meanwhile, is responsible for looping through the debug widget’s keywords (acquired from calling get_widget_keywords()) and adding them to the keywords dictionary via _add_widget_keyword(). Note that this function I chose to not mark as “private” (by leaving off the underscore prefix). There may be reason to allow external code to register a debug widget manually. Though I personally haven’t encountered this scenario yet, the possibility seems plausible enough that I decided to not indicate the function as private.

Let’s add the _add_widget_keyword() function now:


# Adds a widget keyword to the registry.
func _add_widget_keyword(widget_keyword: String, widget_node: Node) -> void:
  var widget_node_name = widget_node.name if 'name' in widget_node else str(widget_node)

  if not _widget_keywords.has(widget_node_name):
    _widget_keywords[widget_node_name] = {}

  if not _widget_keywords[widget_node_name].has(widget_keyword):
    _widget_keywords[widget_node_name][widget_keyword] = widget_node
  else:
    var widget = _widget_keywords[widget_node_name][widget_keyword]
    var widget_name = widget.name if 'name' in widget else str(widget)
    push_error('DebugLayer._add_widget_keyword(): Widget keyword "' + widget_node_name + '.' + widget_keyword + '" already exists (' + widget_name + ')')
    return

That looks like a lot of code, but if you examine it closely, you’ll see that most of that code is just validating that the widget data we’re working with was set up correctly. First, we get the name of widget_node (aka the name as entered in the Godot editor). If that node’s name isn’t already a key in our _widget_keywords dictionary, we add it. Next, we check to see if the widget_keyword already exists in the dictionary. If it doesn’t, then we add it, setting the value equal to the widget node. If it does exist, we push an error to Godot’s error console (after some string construction to make a developer-friendly message).

Interacting with Debug Widgets

At this point, we can register debug widgets so that our debugging system is aware of them, but we still don’t have a means of communicating with the debug widgets. Let’s take care of that now.


# Sends data to the widget with widget_name, triggering the callback for widget_keyword.
func update_widget(widget_path: String, data) -> void:
  var split_widget_path = widget_path.split('.')
  if split_widget_path.size() == 1 or split_widget_path.size() > 2:
    push_error('DebugContainer.update_widget(): widget_path formatted incorrectly. ("' + widget_path + '")')

  var widget_name = split_widget_path[0]
  var widget_keyword = split_widget_path[1]

  if _widget_keywords.has(widget_name) and _widget_keywords[widget_name].has(widget_keyword):
    _widget_keywords[widget_name][widget_keyword].handle_callback(widget_keyword, data)
  else:
    push_error('DebugContainer.update_widget(): Widget name and keyword "' + widget_name + '.' + widget_keyword  + '" not found (' + str(_widget_keywords) + ')')

Our API to interact with debug widgets will work like this: we’ll pass in a widget_path string to update_widget(), split with a . delimiter. The first half of the widget_path string is the name of the widget we want to send data to; the second half is the widget keyword we want to invoke (and thereby tell the widget what code to run).

update_widget() performs string magic on our widget_path, makes sure that we sent in a properly-formatted string and that the widget and widget keyword is part of _widget_keywords. If things were sent correctly, the widget node reference we stored during registration is accessed, and the handle_callback() method called, passing in whatever data the widget node expects. If something’s not done correctly, we alert the developer with error messages and return without invoking anything.

That’s all we need to talk to debug widgets. Let’s make a test to verify that everything works!

Currently, our TestScene scene doesn’t have an attached script. Go ahead and attach one now (calling it TestScene.gd) and add the following code to it:


extends Node

var test_ct = -1

func _process(_delta) -> void:
  test_ct += 1
  if test_ct >= 1000:
    test_ct = -1
  elif test_ct >= 900:
    Debug.update_widget('TextList1.remove_label', { 'name': 'counter' })
  else:
    Debug.update_widget('TextList1.add_label', { 'name': 'counter', 'value': str(test_ct % 1000) })

This is just a simple counter functionality, where test_ct is incremented by 1 each process step. Between 0-899, Debug.update_widget() will be called, targeting a debug widget named “TextList1” and the add_widget widget keyword. For the data we’re passing the widget, we send the name of the label we want to update, and the value to update to (which is a string version of test_ct). Once test_ct hits 900, however, we want to remove the label from the debug widget, which we accomplish through another Debug.update_widget() call to TextList1, but this time using the remove_label widget keyword. Finally, once test_ct hits 1000, we reset it to 0 so it can begin counting up anew.

If you run the test scene right now, though, nothing happens. Why? We haven’t added TextList1 yet! To do that, go to the DebugLayer scene, remove the existing test label (that we created during Part 1), and instance a DebugTextList child, naming it TextList1. Now, if you run the test scene and open up the debugging interface (with Shift + `, which we set up in the previous part), you should be able to see our debug widget, faithfully reporting the value of test_ct each process step.

If that’s what you see, congratulations! If not, review the tutorial code samples and try to figure out what might’ve been missed.

One More Thing

There’s an issue that we’re not going to run into as part of this tutorial series, but that I’ve encountered during my own personal use of this debugging system. To save future pain and misery, we’re going to take care of that now.

Currently, our code for debug widgets always assumes that we’re going to pass in some form of data for it to process. But what if we want a debug widget that doesn’t need additional data? As things stand, because debug widgets assume that there will be data, the code will crash if you don’t pass in any data.

To fix that, we’ll need to add a couple of things to the base DebugWidget class:


# Controls if the widget should allow invocation without data.
export(bool) var allow_null_data = false


# Called by DebugContainer when one of its widget keywords has been invoked.
func handle_callback(widget_keyword: String, data = null) -> void:
  if data == null and not allow_null_data:
    push_error('DebugWidget.handle_callback(): data is null. (' + widget_keyword + ')')
    return
  
  _callback(widget_keyword, data)

We’ve added an exported property, allow_null_data, defaulting it to false. If a debug widget implementation wants to allow null data, it needs to set this value to true.

handle_callback() has also been modified. Before it runs _callback(), it first checks to see if data is null (which it will be if the second argument isn’t provided, because we changed the argument to default to null). If data is null, and we didn’t allow that, we push an error and return without running callback(). That prevents the game code crashing because of null data, and it also provides helpful information to the developer. If there is data, or the debug widget explicitly wants to allow null data, then we run _callback(), as normal.

That should take care of the null data issue. At this point, we’re golden!

Congratulations!

Our debugging system now supports adding debug widgets, and through extending the base DebugWidget class we can create whatever data displays we want. DebugTextList was the first one we added, and hopefully it should be easy to see how simple it is to add other debug widgets that show our debugging information in whatever ways we want. If we want to show more than one debug widget, no problem, just instance another debug widget!

Even though all this is pretty good, there are some flaws that might not be immediately apparent. For instance, what happens if we want to implement debug widgets that we don’t want to be shown at the same time, such as information about different entities in our game? Or what if we want to keep track of so much debugging information that we clutter the screen, making it that much harder to process what’s going on?

Wouldn’t it be nice if we could have multiple debug scenes that we could switch between at will when the debug interface is active? Maybe we’d call these scenes “containers”. Or, even better, a DebugContainer.

That’s what we’ll be building in the next part of this tutorial!

If you want to see the complete results from this part of the tutorial, check the tutorial-part-2 branch of the Github repo.

No Comments

Post a Comment