This is still a Work In Progress, and will take several days to complete. Please be patient!
In my quest to convert my game logic over to Bolt graphs, I settled on my camera code as a candidate. It was isolated from my game logic nicely and demonstrates some intermediate topics that might seem a bit foreign to beginning programmers and those unfamiliar with Unity. I'll do my best to cover these in the appropriate detail, as well as post the relevant graphs if you just want to smash and grab.
In the end, we'll have a number of graphs and this will take several hours to get through, but feel free to come discuss with me on the Bolt Discord if you get stuck! I will, however, assume you've done the official tutorials. If you haven't, you're crazy, they're fantastic.
Alternatively, you can watch the video tutorial here:
You can get the tutorial assets shown in the video here:
I will assume you know how to do the following (all covered in the basic tutorials):
- How to create Flow Graphs, State Graphs, and Super Units, and how to convert between them.
- How to edit and use graph inputs and outputs (Nesting).
- How to create and access variables.
- How events work.
- How to add Units.
We'll be using Human Naming for this tutorial. Even though I'm a Full-time C# developer, I still find it easier to work with, particularly for properties.
(Note for existing users: I use the term graph to describe both embedded and macro, flow and state graphs. It is a generic term to the fullest. I only specify a specific type when I need to.)
Part 1. Setting up our Camera
For this guide, we'll be designing an RTS Camera. What is an RTS Camera? An RTS Camera can be used for any genre of game, but the name comes from the RTS genre that almost exclusively uses a top-down, edge-scrolling camera. For this project, I'll be treating the gameplay as the X-Z plane, with the Y-axis in and out of the game camera. This might seem somewhat counter intuitive (why not X-Y, with the camera on Z?), but it aligns with how Unity views "up" and "down" as well. Remember, we're building a top-down camera, and toggling gravity on an entity in Unity will cause them to fall in the Y axis.
However, if you want to use a different axis system, feel free!
Create a new scene for developing your camera, and add a new State Machine (you did do the official tutorials, right?) and convert your script to a macro. I called mine "RTSCam"
Perfect. Now, to start on the graph, give yourself a Start Flow State, a Normal Flow State, a follow Flow State, and a Frozen Flow State. You might need more, but these are the ones that we'll cover here in this tutorial. Don't worry about setting up each state with the events shown here, we'll get to each at the appropriate time.
|Note: Despite having to make the overall State Graph as a Macro, each of these Flow States are fine to leave as embedded graphs. We don't need to reuse them, and they'll be part of (embedded in) the RTSCam State Macro asset. A graph only needs to be a Macro if it is being put into a Machine on a Prefab OR you wish to reuse it. Sub-graphs such as the Flow States used here are not being directly used by the State Machine, and we're totally fine with them being embedded in the RTSCam graph.|
Our start state's purpose is relatively simple: Establish the variables and starting position, before turning things over to the regular movement of the camera. We go through a special starting state to initialize the camera variables, because we're going to have a lot of them (I have 38 on mine), and this is too many to set up by hand each time we want to use our camera. What we're going to do instead is have a Flow State that, for each variable, looks to see if the variable is already setup by the scene designer (you), and creates and defaults any they haven't.
Didn't catch that? It's okay, let's see it in pictures!
Defaulting camera variables.
Create a new Flow Macro for Initializing and Defaulting a variable. I called mine "Default Variable". This is going to be one we're going to use a lot, so let's take the time to set this up right.
We're going to want an input variable name (string), a value (generic), a target (we'll default to self), and for convenience, a control flow in and out so we can chain them together.
We'll allow the Target to default to Null (or None), but giving a default Value makes no sense. We also allow a default Variable, even though we never expect to use this. By checking "Has Default Value," we can directly input the text on the Unit, without needing a string Literal.
As you can see here, we just directly type the name in, without needing to pass a value. This will come in handy.
First, Let's handle the target selection. We allowed a default value of Null (None) into this parameter, so on our Input unit, let's collapse that with the handy built-in "Null Coalesce".
This will mean that if no target is specified, we'll apply the changes to the current game object. Perfect.
|Null Coalesce is particularly handy vs. Null Check, because we can use it in sub-graphs without providing a control flow. (Usually this won't matter, as Bolt can infer control flow under certain circumstances. Null Coalesce can help you out in the other cases) In this case we have a control flow, but we'll use it later to build macros that don't require control flows and won't rely on inferred control flow.|
Next up, let's check that Variable. You'll remember we allowed a default value of "" for Variable so we could right the value directly in, but we can't allow an actually empty value through. We'll check for the default case, and provide a good contextual error to help us out if we ever call this wrong. (Specifying the context allows us to double-click the error in the console and snap to this object. We specify Self rather than target, because this script will live on Self, and we didn't provide an argument for this. The target is innocent)
Now we need to check if the owner actually has the variable, so we can set it if they DON'T. Remember, we want the user to be able to override these default values on a case by case basis, so we won't override a user set value.
Let's add a check for the variable, and another branch so we can determine when we need to take action.
And now let's set it if the target doesn't have the variable, and wire it and all left over control outputs to our output Unit.
Ugh. Got a bit ugly at the end there, but we wired the False branch to the Set Variable, used our Input Variable and Value on the Target (Output of the Null Coalesce). Then we wired the Debug Log Error, the Branch and the Set Variable outputs so that we continue our parent graph regardless of what happened.
|A quick check for this graph to ensure you got it right: make sure every port is connected. There should be no ports that are available for a connection!|
In this step, we setup a Flow Macro that will allow us to default any un-set variable, allowing us to set up most of our variables in code, and tweak them by setting the variables on the object at development time, overriding the defaults on an object-by-object basis.
Finishing up Start
Well, we have a handy macro to call to help us set up our variables, but what variables do we need? We're going to need a lot, but let's add them as we use them, rather than overwhelm you now. One I do know we're going to need is a variable to control whether our movement is in Update or in FixedUpdate. So let's add that now.
To do this, (knowing I needed a lot of variables) I went ahead and made an embedded Super Unit to house all of my variable initializations. I creatively named it "Initialize Variables", and added my call to Default Variable for "UseFixedUpdate" like so:
And then wired it up in my Start State:
We'll default it to True in this example, and we want the variable on the host GameObject, so we'll leave the Target blank. Let's test it now. Your GameObject shouldn't have a "UseFixedUpdate" variable, but when run, the variable should magically appear (with the value of True)!
Let's test out the other aspect of this. Add the variable manually to the object as a Boolean. Play the scene and notice that whatever value you manually specify is preserved! Fantastic! Using this pattern, you can customize any variables we use in this tutorial without changing the setting globally.
To finish out our Startup, let's add a new default variable "Starting Height" and use that to position our camera. This is for convenience: we can change the default height and have all of our scenes update, so long as we don't override the default. If we used Unity's transform system, we'd have to open all of our scenes to change our camera height!
To do this, I added my default for Starting Height to my Initialize Variables macro. For my game, I set my default to 400, but you can use whatever default you want or override the number on your camera as discussed earlier.
You can structure this however you want, but I used a sequence to allow me to chain groups of variable initializations together.
Regardless of your structure, I set my final Start graph call our Initialize Variables, then set our starting height to our variable (keeping the X and Y as set in the Scene), and rotate the camera to look down.
Sounds about right, and should scale with us as we begin to establish the real meat of our State Graph. In the future, when we want to add new variables, we'll return to Initialize Variables.
Transition to Normal
Our normal state is going to be the state where most of the work is done, and we want to get there immediately. So the transition is going to be super simple:
Well, that was easy.
Part 2. Normal
Panning is perhaps the easiest to think about conceptually. When the player provides some input, move the camera in the X-Axis or Z-Axis accordingly. We probably want to support control via the keyboard (perhaps the arrow keys?), control via edge panning with the mouse cursor, and control via pressing a button and moving the mouse. These are all common patterns in games and usually don't have to be explained to players.
Let's create a default variable for UseKeyboardPanning, UseScreenEdgePanning, and UseMousePanning, so that we can turn each of these behaviors on and off from anywhere, regardless of the state of our camera. Remember to add these to our Initialize Variables graph!
With these toggles in place, let's once again organize our Camera Panning macro so that we lay out each of the phases, which we'll cover each in turn. I set mine up to feed a Sequence with three steps:
- Keyboard Pan
- Edge Pan
- Mouse Pan
Keyboard Input will be defined by having two inputs (horizontal and vertical) and a keyboard movement speed. Let's create a default variable for each of those in Initialize Variables. I used the variable names HorizontalAxisName, VerticalAxisName, and KeyboardMovementSpeed, but you can use any names you like, as this will be the only place we use them.
Then we'll access these variables in our Keyboard Pan graph. What we want to do is get both axis values to construct a movement vector. Remember, we're moving in the X-Z Plane, so we'll zero out the Y movement.
The problem is Get Axis we'll return -1 to 1, and that's not going to be fast enough, so we need to scale our movement vector by or KeyboardMovementSpeed variable.
This still isn't quite complete. We've got a movement vector, but we haven't accounted for how much time has transpired since our last call. If our game is updating quickly, or camera will move faster than if the game is updating slowly. That's not good game design, so let's fix that by scaling it again:
Delta time is a game development term that refers to the amount of time since the last game update. As things happen in your game world and on your player's computer, the number of updates that occur per second will change. When the game slows down, you'll get fewer updates with large Delta Times, and when things are fast you'll get more updates with smaller Delta Time. Smooth Delta time is a weighted average of the Delta Time that tries to, well, smooth out the inconsistencies in your game (See more in the info box). Multiplying by a smoothed delta time will ensure that we get a consistent movespeed of the camera that is independent of the update rate of our logic. We'll use the results of these calculations as how much we need to move the camera by.
|Note: In Unity, there are actually three measures of Delta Time. There is DeltaTime, FixedDeltaTime, and SmoothDeltaTime. This, in part, is due to a split between Update and FixedUpdate. Update happens as frequently as possible, while FixedUpdate tries to always happen 60 times a second. Physics calculations always happen in FixedUpdate, and most game logic lives in Update, but understanding the two is critical.|
Delta time has a special interaction with Update and FixedUpdate, in that it will return either the time since the last Update or the time since the last FixedUpdate, depending on which call you are in. If we were using a Get Delta Time call here, we wouldn't have the problem we're about to discuss in the following paragraphs.
However, we're using Get SmoothDeltaTime, which is always based on the Update rate (without the switching behavior that DeltaTime uses). You wouldn't want SmoothDeltaTime for something like character movement, because you want to directly account for the delay between frames such that they end up in the correct location regardless of lagging game frames. For actual gameplay objects, use Delta Time instead of Smooth Delta Time. However, for a camera, we're using SmoothDeltaTime on the camera because this will smooth out the camera movement, even if the game lags during the movement. While DeltaTime would ensure that our camera moved the correct distance, it would do this with jumps and jitters that decreases the viewing pleasure. Using a SmoothDeltaTime adjustment results in smoother camera movement, at the cost of the accuracy of movement. For a camera, smoothness matters.
However, we need to modify this logic, as Smooth Delta Time is the time of the last Update cycle, but if we're using FixedUpdate we're updating our logic outside of the Update and so we'll be adjusting using the wrong number! So let's correct that using a Macro we can reuse.
I created a new Flow Macro called CameraDeltaTime (creative, I know) that simply returns the correct time scalar for our UseFixedUpdate Variable, via the Select Unit, which will return one of the two time constants, as determined by the input boolean:
And modify our Keyboard Pan to use it instead.
Great, so we should have a smoothed movement!
But now we need to actually take care of moving the camera. There is a potential problem, here. If we've rotated the camera, then what the player thinks is "up" isn't actually "up" in our gameworld! We need to move in relation to the camera's current rotation. Since we'll need to replicate this change for our other panning movements, let's create another Macro to contain our screen-space to worldspace movement. We'll give it a Control Flow input and a Vector3 value input to store our world-space vector.
Since we're designing a top-down camera, we only want panning movement in the X-Z plane, so we'll create a rotation Quaternion that only includes the camera's world y-axis rotation. and multiply our movement vector by it. This will give rotate our movement vector by our Camera's rotation.
That's it for this macro, let's go back to our Keyboard Pan and use it.
And a view of the entire Keyboard Pan graph:
And with that, we're on to Mouse Panning (because it's easier than Edge Panning)!
Mouse panning is a lesser used mechanic that consists of the player pressing a key to activate panning and then moving the mouse. Sometimes this is a key on the keyboard, sometimes this is a mouse button. Fortunately, it is going to be similar to Keyboard panning, so this should seem pretty similar.
We're going to need a variable to control how fast the map moves, so create a new entry in Default Variables for a MousePanningSpeed float variable and a MousePanningKey Key variable. A Key is an enum that defines all the potential keys on a standard English Keyboard, as well as standard Mouse Buttons. In a full game, you'd take this key from whatever mechanism you use to allow hotkey customization, but for here we're going to restrict it to a key of our choosing.
Then, in our Mouse Pan Macro that we set up earlier, let's set up a check to ensure that we should actually go ahead with the mouse panning. We don't want random mouse movements triggering panning the camera. We only want to activate it when the player manually triggers it. I set up a condition like the following:
Here, we check that the player is pressing the MousePanningKey, which we'll use to trigger construction of a movement vector just like we did for keyboard panning in the previous section.
But waiiiit... this looks like what we did for keyboard panning, doesn't it? We get an X, Z movement vector, scale by speed and our time Macro. Next up would be the Transform Inverse direction. Instead, why don't we create a macro that will take the X and Z movement and a movement speed that will take care of panning for us?
Let's transform our Translate World XZ Movement Macro and turn it into a full-featured Pan macro by removing the GameObject input, and adding two floats for X and Z movement and a float for speed! We can incorporate our vector construction and scaling by making the copying those units and passing the scaled vector to the multiply unit:
Rename this macro Pan, and double back to change our two panning graphs to use the new modifications!
Here's the updated Mouse Pan.
Much simpler! Let's look at Keyboard Pan, which also gets even simpler!
Much simpler! Give everything a test. Everything works, but the mouse panning tracks with the cursor, rather than opposite as is common with RTS cameras. This is easily handled by inverting the mouse axis movement components:
Edge Panning is the most intuitive of all of the control schemes. When the cursor is put at the edge of the screen, the camera should pan until the user moves the mouse away from the edge of the screen.
Fortunately, since Unity offers mouse position in screen units, this is simple to detect, as we don't have to do weird transformations to the mouse position units. We're also going to use a handy unit to detect if our mouse position is within a logical screen-box:
A Rect is a simple data type that defines a rectangular area, and we can get a boolean out indicating if our mouse position is within the rectangle. Unfortunately, we're going to have to use four rectangles, one for each edge of the screen. Fortunately, this means we get diagonal edge panning for free, as the rectangles will overlap in the corners.
It also means we're going to have to create our rectangles. Define a default float variable for ScreenEdgeBorderSize and ScreenEdgeMovementSpeed. I used the value of 25 for my border size, but feel free to use whatever value is natural for your targeted resolution range. Once we do this, creating rectangles is easy:
For the next two, we have to offset them by the screen size to get them in position.
And that's all four created! For now, let's assign each one to a graph variable, so we can check them later and build a panning vector!
Be sure to wire up each one of the Create Rect outputs to a graph variable. Notice that Bolt is giving us a warning here: that the output of our Create Rect is being used, but we haven't wired it to anything.
We could chain our Create Rects, but in the interest of preventing Spaghetti Graph, let's use a Sequence, instead. That should allow us to design off our graph quite nicely.
You'll notice I left a fifth sequence open. That's going to be for constructing our vector! To do this, we need to simplify our rectangles into the two axes: X and Y (remember, screen-space!). Fortunately, our user is only going to be able to put their cursor into one extreme for each axis, so we can think of each axis panning movement as either negative, positive, or zero.
Let's start with the Y axis and see if we can begin to apply this simplification. First, let's establish the checks for whether the cursor is in either the up or down rectangle.
This will give us 1 if the cursor is in the UpRect, -1 if it is in the DownRect, and 0 if it is in neither. This is perfect for our Pan macro, so wire it up to the Z axis:
Halfway there! But our graph is starting to look ugly. :-(
This is going to get event worse once we duplicate the logic we built for the Screen Y axis for the Screen X axis. In the interest of simplifying things, let's transform our our axis value generation into a new Flow Macro. I called mine RectToAxisValue, but use your own judgement and superior naming skills.
It'll need two Rect inputs, and one float out. Copy and paste all of the Y Axis multiplier we just created and remove the Get Variable units. We'll wire up our Macro inputs instead.
And that's it! We feed in whatever positive axis Rect and Negative axis rect, and it'll give us the multiplier out. Using this, we can get rid of the Graph Variables for UpRect, DownRect, LeftRect and RightRect and finish off our graph.
(Note: I goofed while snapping this picture. Yours should be named Super Units, as yours is a macro. I snapped this picture before the conversion.)
With a little more work, we can trim this up even more. We can move the Input unit to directly to our pan, and let Bolt figure out how to order the inputs. Then, we can reorder our rectangles to make the graph all nice and pretty.
Much better! And Finally! Panning is done! You've made it through the slog!
Customer support service by UserEcho