Part 7: Custom Mouse Cursor
Now that we have a basic Unit and Building in our world, along with the ability to select them and display that fact to the user, let's improve our HUD a little further. One of the things that is useful in games (actually, on a computer in general) is having the mouse cursor change when what the Player can do changes. For example, hovering over a Building you can select would show a different cursor than hovering over the ground when a Unit that can move is selected. These context changes are great simple ways to inform the Player what they can do. This time we will add in the ability to change the cursor based on a cursor state. The HUD itself will be responsible for actually drawing the correct cursor. But it will be up to a number of things in our game to determine what the actual state of the cursor needs to be.
Custom Cursors
The first thing we want to be able to do is to draw a custom cursor. As part of this we will introduce the concept of cursor state. Drawing a cursor will be based on that state. This also means that changing state does not need to worry about any of the draw code. This form of isolation and independence is a good thing.
Before we begin drawing we should create a place to store all of the different cursors that we are going to use. Inside the HUD folder create a new folder called Cursors. We may as well put all of our cursors into folders now, so that when we want to use them we can just grab the ones we want. Inside the new Cursors folder create folders with the following names: Attack, Harvest, Move, Pan, Select. As you can see, we are going to have a number of different states that we will display to the user with our cursor. Many of these actions will not be added until later, but it is still useful to have the cursors there.
Here are the cursors I am using for this project, feel free to use them if you wish. Each of these needs to be saved into the appropriate folder.
- Attack Cursors
- Harvset Cursors
- Move Cursors
- Pan Cursors
- Select Cursor
Now that we have some images to work with, we need a way to be able to access these within our HUD. Add the following variables to the top of HUD.cs
public Texture2D activeCursor; public Texture2D selectCursor, leftCursor, rightCursor, upCursor, downCursor; public Texture2D[] moveCursors, attackCursors, harvestCursors;
and then drag the appropriate images onto them in Unity. Just make sure that when you are adding images to the array variables that you add them in the right order (1, 2, 3, ...) otherwise the animation sequence that we end up using will look weird. It is time, now, to get our custom cursor drawing. Add the following method to HUD.cs.
private void DrawMouseCursor() { if(!MouseInBounds()) { Screen.showCursor = true; } else { Screen.showCursor = false; GUI.skin = mouseCursorSkin; GUI.BeginGroup(new Rect(0,0,Screen.width,Screen.height)); UpdateCursorAnimation(); Rect cursorPosition = GetCursorDrawPosition(); GUI.Label(cursorPosition, activeCursor); GUI.EndGroup(); } }
If the mouse is over the HUD area of the screen we will use the system mouse cursor (although we could easily change this at a later date if we wanted to). If not we need to cancel the system cursor and draw our own in its place. To make sure that things always behave we will define a skin for our cursor (a new default skin will do fine) and define the group drawing area to be the entire screen. We then update any animation our cursor might have to make sure we have the correct frame set for our cursor, find out where it needs to be drawn on screen, and then draw it there using a Label.
As usual, there are some steps we need to take in order to make this method work. First, create a new skin inside your skins folder (in the HUD folder) called MouseCursorSkin. The one setting we want to change is to set the Border, Margin, and Padding for a Label to be 0. This will make sure that our cursor image is drawn as large as possible. (Note: I have only recently fixed this, so the skin on github is not correct until Part 17) Now create a reference to yet another GUISkin at the top of HUD.cs
public GUISkin mouseCursorSkin;
and attach the newly created skin to your HUD in Unity. Finally, we need to create the two methods which do most of the work for us. Let's start with UpdateCursorAnimation().
private void UpdateCursorAnimation() { //sequence animation for cursor (based on more than one image for the cursor) //change once per second, loops through array of images if(activeCursorState == CursorState.Move) { currentFrame = (int)Time.time % moveCursors.Length; activeCursor = moveCursors[currentFrame]; } else if(activeCursorState == CursorState.Attack) { currentFrame = (int)Time.time % attackCursors.Length; activeCursor = attackCursors[currentFrame]; } else if(activeCursorState == CursorState.Harvest) { currentFrame = (int)Time.time % harvestCursors.Length; activeCursor = harvestCursors[currentFrame]; } }
This method is relying on two global variables which we need to declare at the top of HUD.cs.
private CursorState activeCursorState; private int currentFrame = 0;
CursorState is actually an Enum. These are useful for declaring things like a collection of states (as opposed to using an array of strings that we then have to access in annoying ways). As you can see above, checking to see whether we are in a certain state is quick and easy. Inside the RTS folder create a new C# script called Enums.cs and replace the entire file with the code below.
namespace RTS { public enum CursorState { Select, Move, Attack, PanLeft, PanRight, PanUp, PanDown, Harvest } }
If we ever want to handle more cursor states in our game all we need to do is add extra entries to this Enum and then we can perform the relevant checks elsewhere in our code. Remember, if we want to access this Enum from a class we need to add
using RTS;
above the class definition.
Now to evaluate that method and see what it is actually doing. Each branch of the if statement is handling a different cursor which has more than one image for it. If there is only one image we do not need to handle an animation. The logic for each cursor is the same, all that is changing is the array of images being referenced. The first line determines the current frame. This does so by making sneaky use of some math theory, which I will go into very briefly here. The % operator in C# performs a division on two integers (whole numbers) and then returns the remainder. For example: 7 / 2 gives us 3 lots of two with one left over (3 * 2 + 1 = 7). So 7 % 2 will return the one left over. This is very useful when we want to access a particular entry in an array based on some changing element - in this case time. Unity provides us the current elapsed game time (in seconds) through Time.time. By casting this to an int we round the current time down to the nearest second (so 116.23 seconds becomes 116 seconds). We then divide this value by the number of images we have for our cursor (given by the length of the array for that cursor's images) and retrieve the remainder. This is guaranteed to be a valid index in our array. The index position will advance by one every second, and it will automatically wrap back to the start of the array (since a match with the length of the array gives a remainder of 0). This one simple line of code is extremely powerful, and we will use this underlying concept in other places as we progress. Once we have the index value we can set the active cursor to the appropriate image for the current cursor state. (For those interested in learning more look up Modular arithmetic)
There is still one more method for that we need to create in HUD.cs: GetCursorDrawPosition().
private Rect GetCursorDrawPosition() { //set base position for custom cursor image float leftPos = Input.mousePosition.x; float topPos = Screen.height - Input.mousePosition.y; //screen draw coordinates are inverted //adjust position base on the type of cursor being shown if(activeCursorState == CursorState.PanRight) leftPos = Screen.width - activeCursor.width; else if(activeCursorState == CursorState.PanDown) topPos = Screen.height - activeCursor.height; else if(activeCursorState == CursorState.Move || activeCursorState == CursorState.Select || activeCursorState == CursorState.Harvest) { topPos -= activeCursor.height / 2; leftPos -= activeCursor.width / 2; } return new Rect(leftPos, topPos, activeCursor.width, activeCursor.height); }
The basic position we want to start with for any cursor is the position on screen where the mouse cursor would be drawn. Remember that Unity has the draw coordinate starting in the bottom left corner, so we need to take the screen coordinates for the mouse (which start from the top left corner) and invert these to make sure that the cursor will be drawn in the correct position.
We now want to tweak the position of the mouse based on its current state. There are a number of reasons why we might want to do this. The most common reason is that a large number of the cursors we will use actually have the point where we think the cursor is as the centre of the image, not the top left corner (which is where we will be starting the drawing of our image from). In all of these cases we need to shift the draw position up and to the left by half the width and half the height of our image. The other special cases here (at the moment) are for when we are panning right or down. Remember that this happens when the mouse is positioned on the far right or bottom of the screen. If we were to draw the cursor from the top left of our image in either of those situations, we would be drawing the cursor off-screen, which is no use at all. So we need to make sure that the draw position is shifted back on screen appropriately in theses scenarios.
It turns out that there is one more thing we need to do before we can even show a custom cursor on screen at the moment. We need to set what the default cursor will be for our HUD. But before we do that, let's create a method that will allow us to easily change the cursor state for our HUD. This needs to be added to HUD.cs.
public void SetCursorState(CursorState newState) { activeCursorState = newState; switch(newState) { case CursorState.Select: activeCursor = selectCursor; break; case CursorState.Attack: currentFrame = (int)Time.time % attackCursors.Length; activeCursor = attackCursors[currentFrame]; break; case CursorState.Harvest: currentFrame = (int)Time.time % harvestCursors.Length; activeCursor = harvestCursors[currentFrame]; break; case CursorState.Move: currentFrame = (int)Time.time % moveCursors.Length; activeCursor = moveCursors[currentFrame]; break; case CursorState.PanLeft: activeCursor = leftCursor; break; case CursorState.PanRight: activeCursor = rightCursor; break; case CursorState.PanUp: activeCursor = upCursor; break; case CursorState.PanDown: activeCursor = downCursor; break; default: break; } }
We set the active cursor state to the new state specified and then we update the cursor accordingly. You will notice that if a cursor has multiple images we are using the same method as before for selecting which frame of the animation to show. We do it this way so that next update the animation will flow seamlessly, rather than jumping from the first frame to whatever UpdateCursorAnimation() decides the frame should be.
Okay, all of our framing is in place, now we just need to call it from the right places. In HUD.cs, add the following line to the end of Start()
SetCursorState(CursorState.Select);
and this line to the end of OnGUI() (inside the check for whether the player is human).
DrawMouseCursor();
If you run this from inside Unity you should see that our Select cursor is now being drawn while the mouse is inside the playing area. (Sometimes in this demo mode Unity does not hide the system mouse cursor when it should, selecting an object should fix this. Either way, you should still be able to see the Select cursor being drawn.)
Changing Cursor State
It turns out that most of the framework for changing our cursor state is in place already. All that is left is to implement the logic of when this should change throughout our codebase. For now, let's change the cursor when we are panning the map around. This is a good simple way to inform the Player of what is happening. To make this happen we need to add some code to MoveCamera() inside UserInput.cs. Update the code which handles vertical and horizontal movement to the following
bool mouseScroll = false; //horizontal camera movement if(xpos >= 0 && xpos < ResourceManager.ScrollWidth) { movement.x -= ResourceManager.ScrollSpeed; player.hud.SetCursorState(CursorState.PanLeft); mouseScroll = true; } else if(xpos <= Screen.width && xpos > Screen.width - ResourceManager.ScrollWidth) { movement.x += ResourceManager.ScrollSpeed; player.hud.SetCursorState(CursorState.PanRight); mouseScroll = true; } //vertical camera movement if(ypos >= 0 && ypos < ResourceManager.ScrollWidth) { movement.z -= ResourceManager.ScrollSpeed; player.hud.SetCursorState(CursorState.PanDown); mouseScroll = true; } else if(ypos <= Screen.height && ypos > Screen.height - ResourceManager.ScrollWidth) { movement.z += ResourceManager.ScrollSpeed; player.hud.SetCursorState(CursorState.PanUp); mouseScroll = true; }
and then add the following check at the bottom of the method
if(!mouseScroll) { player.hud.SetCursorState(CursorState.Select); }
to make sure that we do not get stuck with a Pan cursor. If you run this now you will see that the cursor is changing, but we cannot see it when we are panning up or right since our mouse is over the HUD. Let's fix that, since it will leave players highly confused as to what is going on. At the top of DrawMouseCursor() we need a better check to see if the mouse is in the HUD or not. This should do, so add it to the start of the method
bool mouseOverHud = !MouseInBounds() && activeCursorState != CursorState.PanRight && activeCursorState != CursorState.PanUp;
and then replace the
if(!MouseInBounds())
check with
if(mouseOverHud)
to determine whether to use the system cursor or not.
And that brings us nicely to the end of this part. We now have custom cursors at our disposal and an easy way to change those at will. As our Units and Buildings gain more abilities (or we add more specialized versions of them into our game), we will be able to easily change the cursor whenever we want / need to. The full code for the end of this stage is up on github under the commit for Part 7. We now have enough framing in place for our game that we can begin to add in some interactivity. We will start this off next time by adding some basic movement to our Units.