Developing Indicators for Puzzle Designer
Hello all! Today we're going to be looking a bit more at how we can leverage editor-only functionality to give our puzzle designer a bit more information while they're working. A few hours for the programmer can save our puzzle designer dozens of hours in headache and debugging, so it definitely seems like a fair tradeoff, especially as the game grows and features get a slight bit more complicated.
Below, you can see a video that showcases a working version of what I walk through below.
First, though, I'll need to go over what our puzzle pieces are so we can fully understand the context these tools are implemented in.
Let's dive in!
First, I'll get you up to speed with the pieces we have in a puzzle sequence.
Chain Container - This is a non-interactive object that just facilitates communication between puzzle pieces where necessary. It also serves as a way to track which puzzle pieces belong with which group.
Receiver - The receiver is the base class for all other puzzle pieces, and it serves as our destination.
Interactable - The interactable is the base class of all of the classes with which the user directly or indirectly interacts.
Those are the three main components of our puzzle. We won't be going over how they specifically function together, just enough of what they are to explain how this indicator tool can help the designer.
The goal here is for us to be able to lay out enough information that the designer can either easily see or be taught what the indicators are trying to tell them. So here are the three icons I'm using.
I explained to the designer what all of the symbols mean.
The first means that the actor they're looking at hasn't been referenced by a Chain Container. The second means that some interactable hasn't been targeted by any of the puzzle pieces. The third is pretty self explanatory.
So we also need to consider runtime impact. Because of how we'll need to render these, we need to find a way to, in C++, deal with this so that we don't have to render this in a development build.
We'll go over all of this together, so let's start with how we add icons like this to an actor at all.
So we'll want to take advantage of the UMaterialBillboardComponent, which requires the use of a material rather than a sprite. This makes things a bit more complicated, but it enables us to bypass the render depth so that the symbol will display on top of everything else. This is really important for a tool like this.
So, we need a few variables in our base class, the Interactable.
Firstly, you'll notice that they're surrounded by a preprocessor condition. This directive, WITH_EDITOR, ensures that the code inside won't be compiled in non-editor builds. This will help us with performance and also keep us honest when it comes time to cook, as we'll likely pop a couple of errors via UnrealFrontend.
So, let's go through these one by one.
First, the variables. We declare a variable for our billboard component. We also declare UTexture2D variables that we'll use to store our images. The UMaterialInterface will be the material we plan to use as a parent for our Dynamic material, which we've also declared. The UCurveFloat is a required parameter for setting our billboard, so we'll be declaring and accessing one of these as well.
Now let's look at these functions again.
PostEditChangeProperty comes with Unreal, as you might tell via the virtual and override keywords. However, it's worth noting here that if you ever plan to use this function, it must always be wrapped in the WITH_EDITOR directive. Otherwise, your cooked builds will fail because the superclass wraps them the same way.
We also wrap the DesignCheck in this directive for the same reason; we don't want this functionality running or trying to run in a shipped build.
So now that we have our functions, let's look at a few things we'll need in the editor.
So we need to add a few assets to the editor that we'll later reference with constructor helpers. Let's take a look at what we'll need.
So that's the 3 images, with their compression settings at UserInterface2D (RGBA), the material, (M_errormsgs), and a Float Curve asset, DistanceToSize, with a single key set to (1.0, 1.0).
We'll reference all of these assets in C++, but let's take a look at that material first.
SETTING UP THE MATERIAL
Believe it or not, setting up the material is pretty easy.
We create a 2D texture parameter in anticipation for our Dynamic material instance. We set the Blend Mode to Translucent and the shading model to Unlit. We pass RGBA into the emissive color, then use the alpha channel as the key for Opacity. Viola, material's done. Oh, wait, one more thing. You'll want to search for and click on this "Disable Depth Test"
This is how we'll get the icons in front of the actors they're on (as well as everything else). Now we're done with the setup, let's deal with implementation!
IMPLEMENTATION OF DesignCheck()
Several classes are involved in the implementation that results in what the designer can see. So first, let's talk through the rules that govern when we want to show the "error" icons.
Whenever we have an Interactable that isn't being referenced by any puzzle pieces, we want to show that target icon. This means we're going to check the entire world, which is okay at design time and on an event. If we can't find a single other object that inherits from Interactable that targets our actor, then we show that icon.
If the Interactable or any inherited class isn't referenced by a Chain Container, then we need to show the NoChain icon.
So first, let's go to the Chain Container. Whenever we add or remove references, we need to force the rest of the Interactables in our level to update. That means we'll be making use of the PostEditChangeProperty function. Below is how we're implementing the logic inside of that Chain Container.
So the first thing we need to do is use ConstructorHelper to get the asset paths we'll be using. We implement this in the highest reasonable superclass, in this case, the Interactable.
Wrapping these in the preprocessor WITH_EDITOR condition ensures that we don't do any of this in a shipping build, so we're not wasting resources the player may need.
This is how we get C++ variables to point towards editor-imported assets without having to use a Blueprint class to reference them. So now we've got all of our variables hooked up to the right assets. Where do we actually start?
Well we start with the Interactable's OnConstruction script, like so.
This runs whenever we move an object in the editor, but not if we change the object's other properties, like any references it contains or any member variable values. We have a solution to that, but we'll get there in a minute. For now, let's look at what our implementation of DesignCheck is actually doing. There are two versions of this for different classes (some of which undoubtedly could be abstracted away with yet another superclass). First, let's look at and go through the Interactable's design check.
We make our MID out of the material we grabbed via our constructor helpers, and then we set its "Status" parameter immediately. We need to build an FMaterialSpriteElement and set the elements of our MaterialBillboardComponent to the value of just that object we're creating. MaterialBillboardComponent's can have multiple materials active at once, so we need to ensure that every time we run this check, we are only ever setting the value of that variable to an array with one element. From there, we get all actors of this class and iterate through them, checking each actor's "LinkedActors" array to see if the object running this check is in that list. And if we are, we do an early return, maintaining the "AllGood" icon we set at the beginning. If we get to the end without having located a reference to this class, we set the icon to "NotTargeted". Now, let's take a look at the Receiver and see how it implements this same idea a bit differently.
As you can see, the logic is nearly identical (which showcases some kind of flaw in my programming practices.) However, in the iteration, we're looking to see if the receiver is referenced by any of the puzzle chains. If not, we're setting the material to "NoChain". You'll also note that we don't run Super:: here. That's due to the slightly conflicting logic in the for loop. Undoubtedly this could be optimized, but as this is a designer-only tool, the optimization isn't a huge issue or priority.
To recap, we're getting a list of actors from the world, seeing if they're referencing the actor doing the iteration, and setting an error image if there aren't any references found.
Now let's look at how we keep this live in the editor.
IMPLEMENTATION OF PostEditChangeProperty()
Both the Chain Container and the Interactable need to override this function separately, as they only share AActor as a superclass.
Here's how we do it with the Chain Container.
Whenever the Chain Container's properties are edited (regardless of which ones), we get all of the interactable actors in the world and run their OnConstruction event. If you recall from earlier, that's where our DesignCheck() is. This means we run the DesignCheck() again, determining whether the actor should show an error icon. So if we add or remove one of the actors in the Chain Container's array variable, we'll see the error image change in real-time in the editor.
Likewise, we need to implement this to the Interactable.
It's the exact same logic, except it's using its own static class.
This does imply that we could put this in a static function library and run it with a world context to avoid this repetition. In fact, it's probably a good idea to make those DRY changes to this feature to make it more maintainable.
If you've made it this far, I appreciate you sticking with me. These kinds of topics can get pretty boring, like a friend telling you about an inane dream they had.
However, I did want to share this adventure in tool development, as it was really satisfying to get it working as desired. Hopefully you'll be able to put this information to good use!