Tactics RPG Anchored UI

The user interface (UI) is one of those areas you always end up spending a lot of time implementing, and every game needs one in some form or fashion. I have built up a variety of reusable libraries in the past, but with Unity’s new UI tools I find myself starting over again. If you’re like me, the anchor and pivot system provided by a RectTransform may have been a bit confusing. I like working with it pretty well in the inspector, because I can modify the anchors and pivot to any corner for easy placement. In code, it wasn’t quite as easy, so this lesson is dedicated to the creation of a few reusable components which will, hopefully, make all our lives easier for awhile.

Layout Anchor

The first component I want to create will provide an easy way to move a RectTransform in relationship to its parent RectTransform, as easily as I am able to via the inspector. The process needs to be as easy as setting text alignment – in fact, I reuse a TextAnchor enum for this purpose. I want to be able to snap it into place, or animate it, and maintain full control over timing and easing curves, etc.

Create a new subfolder in Scripts/Common/ called UI and add a new script there called LayoutAnchor. Because this script will operate on a RectTransform, we can use a tag to make it a required component:

using UnityEngine;
using System.Collections;

[RequireComponent(typeof(RectTransform))]
public class LayoutAnchor : MonoBehaviour 
{
	
}

Everything our new script will do is based on its own RectTransform and its parent RectTransform, so we will want to add fields for both, and then assign them in the Awake method. In the event that this object is not part of a view hierarchy, I throw an error message as a warning.

RectTransform myRT;
RectTransform parentRT;

void Awake ()
{
	myRT = transform as RectTransform;
	parentRT = transform.parent as RectTransform;
	if (parentRT == null)
		Debug.LogError( "This component requires a RectTransform parent to work.", gameObject );
}

When positioning our RectTransform, we will need to know the general offsets to use based on the location of the anchor we want and the size of the RectTransform’s rect. Let’s make a method which allows us to get this information from either of the RectTransforms we might want to pass to it:

Vector2 GetPosition (RectTransform rt, TextAnchor anchor)
{
	Vector2 retValue = Vector2.zero;

	switch (anchor)
	{
	case TextAnchor.LowerCenter: 
	case TextAnchor.MiddleCenter: 
	case TextAnchor.UpperCenter:
		retValue.x += rt.rect.width * 0.5f;
		break;
	case TextAnchor.LowerRight: 
	case TextAnchor.MiddleRight: 
	case TextAnchor.UpperRight:
		retValue.x += rt.rect.width;
		break;
	}

	switch (anchor)
	{
	case TextAnchor.MiddleLeft: 
	case TextAnchor.MiddleCenter: 
	case TextAnchor.MiddleRight:
		retValue.y += rt.rect.height * 0.5f;
		break;
	case TextAnchor.UpperLeft: 
	case TextAnchor.UpperCenter: 
	case TextAnchor.UpperRight:
		retValue.y += rt.rect.height;
		break;
	}

	return retValue;
}

I am doing something here that some of the beginners may not understand – I am intentionally not using a break statement between each of the case statements in my switch statement. This allows cases to fall through each other until a break is reached. In other words, any of the Vertical Center anchor settings will modify the return values ‘x’ value by half, and any of the Vertical Right anchor settings will modify the return value by the full width of the RectTransforms rect.

The next method is a bit confusing, I apologize in advance, but I’m not sure I understand the pivot and anchor system 100% myself – I just kept fiddling with it until I got something which worked. It’s purpose is to find the value you would use to make a RectTransform appear in the correct place based on the anchor points you specify. I wanted this to work regardless of the RectTransform’s own pivot and anchor settings, which is why this method is more complex than it could be. For example, if you could assume that both the parent and current RectTrasnform had values of zero for all anchor and pivot settings then the calcuations would have been very easy to determine. However such an assumption doesnt allow for things like anchors which stretch a UI element based on the parent canvas, screen aspect ratio, etc. If any of my brilliant readers out there knows a way to simplify this any further, please let me know:

public Vector2 AnchorPosition (TextAnchor myAnchor, TextAnchor parentAnchor, Vector2 offset)
{
	Vector2 myOffset = GetPosition(myRT, myAnchor);
	Vector2 parentOffset = GetPosition(parentRT, parentAnchor);
	Vector2 anchorCenter = new Vector2( Mathf.Lerp(myRT.anchorMin.x, myRT.anchorMax.x, myRT.pivot.x), Mathf.Lerp(myRT.anchorMin.y, myRT.anchorMax.y, myRT.pivot.y) );
	Vector2 myAnchorOffset = new Vector2(parentRT.rect.width * anchorCenter.x, parentRT.rect.height * anchorCenter.y);
	Vector2 myPivotOffset = new Vector2(myRT.rect.width * myRT.pivot.x, myRT.rect.height * myRT.pivot.y);
	Vector2 pos = parentOffset - myAnchorOffset - myOffset + myPivotOffset + offset;
	pos.x = Mathf.RoundToInt(pos.x);
	pos.y = Mathf.RoundToInt(pos.y);
	return pos;
}

Now that we can determine where to place our RectTransform, lets add a convenient method to actually do it. The value might be useful by itself in some cases, but most of the time I imagine we will just want it to take care of itself:

public void SnapToAnchorPosition (TextAnchor myAnchor, TextAnchor parentAnchor, Vector2 offset)
{
	myRT.anchoredPosition = AnchorPosition(myAnchor, parentAnchor, offset);
}

Let’s add one last option which will allow us to animate moving the RectTransform into position. Note that this bit of code wont compile until you add some more animation extensions which we will add next. Because the method returns a Tweener you can modify all aspects of the animation such as how long it should take or what kind of animation curve to use. You could even register for animation completion events etc.

public Tweener MoveToAnchorPosition (TextAnchor myAnchor, TextAnchor parentAnchor, Vector2 offset)
{
	return myRT.AnchorTo(AnchorPosition(myAnchor, parentAnchor, offset));
}

Anchor Position Tweener

In the Scripts/Common/Animation/ folder add a new script named RectTransformAnchorPositionTweener. This is a very simple script which maintains a reference to its own RectTransform and sets the interpolated Vector (from the update loop) as the anchoredPosition value.

using UnityEngine;
using System.Collections;

public class RectTransformAnchorPositionTweener : Vector3Tweener 
{
	RectTransform rt;

	protected override void Awake ()
	{
		base.Awake ();
		rt = transform as RectTransform;
	}

	protected override void OnUpdate (object sender, System.EventArgs e)
	{
		base.OnUpdate (sender, e);
		rt.anchoredPosition = currentValue;
	}
}

Animation Extensions

When using my animation libraries, I prefer having animation extensions that allow whatever it is being animtated, to animate itself. It’s not necessary, but I find it makes my code more readable:

using UnityEngine;
using System;
using System.Collections;

public static class RectTransformAnimationExtensions 
{
	public static Tweener AnchorTo (this RectTransform t, Vector3 position)
	{
		return AnchorTo (t, position, Tweener.DefaultDuration);
	}
	
	public static Tweener AnchorTo (this RectTransform t, Vector3 position, float duration)
	{
		return AnchorTo (t, position, duration, Tweener.DefaultEquation);
	}
	
	public static Tweener AnchorTo (this RectTransform t, Vector3 position, float duration, Func<float, float, float, float> equation)
	{
		RectTransformAnchorPositionTweener tweener = t.gameObject.AddComponent<RectTransformAnchorPositionTweener> ();
		tweener.startValue = t.anchoredPosition;
		tweener.endValue = position;
		tweener.easingControl.duration = duration;
		tweener.easingControl.equation = equation;
		tweener.easingControl.Play ();
		return tweener;
	}
}

Test Drive

Let’s take our new components out for a test drive! Create a new scene (from the menu bar choose File->New Scene. Add a Panel (from the menu bar choose GameObject->UI->Panel). Select the Panel and add the Layout Anchor component.

Create a temporary script (place it wherever you like – perhaps a Temp folder) named AnchorTests. This script will loop through all of the combinations of anchors and snap or move (animated) the panel accordingly and allow you a chance to watch and make sure that everything works as expected. Attach the AnchorTests script to the same Panel which had the LayoutAnchor.

using UnityEngine;
using System.Collections;

public class AnchorTests : MonoBehaviour 
{
	[SerializeField] bool animated;
	[SerializeField] float delay = 0.5f;

	IEnumerator Start ()
	{
		LayoutAnchor anchor = GetComponent<LayoutAnchor>();
		while (true)
		{
			for (int i = 0; i < 9; ++i)
			{
				for (int j = 0; j < 9; ++j)
				{
					TextAnchor a1 = (TextAnchor)i;
					TextAnchor a2 = (TextAnchor)j;
					Debug.Log(string.Format("A1:{0}   A2:{1}", a1, a2));
					if (animated)
					{
						Tweener t = anchor.MoveToAnchorPosition( a1, a2, Vector2.zero );
						while (t != null)
							yield return null;
					}
					else
					{
						anchor.SnapToAnchorPosition(a1, a2, Vector2.zero);
					}
					yield return new WaitForSeconds(delay);
				}
			}
		}
	}
}

Reset the Panel’s RectTransform values, a good 100×100 panel is perfect for watching the snapped positions (but note that you can experiment with different values for the Anchors and Pivot and it should still work as expected). I also chose to give our Panel a solid red color in its Image component so it was easier to see.

 photo Reset Rect Transform_zpsr5ob8upv.png

Play the scene. I like to have a Scene view and Game view up at the same time, so I can also watch the positions which move the panel outside of the camera area. Keep the scene around because we will add another test in a bit.

Panel

Let’s add another very reusable component inside the Scripts/Common/UI folder called Panel. It wont hurt my feelings if you prefer a different name to avoid confusion with a Unity Panel (which is really just a GameObject with a RectTransform – no Panel component here). I think Panel is a fitting name though and it isn’t used (via a class name) so I think it’s fair game.

The purpose of this script will be to define target positions and then work with our LayoutAnchor to snap or move to them when necessary. For example, you may want to define which anchors and offsets to use when the panel is supposed to be On-Screen and different anchors and offsets when it is Off-Screen. If you were doing some sort of Navigation View Stack you might have a few different Off-Screen positions (one for when it is not part of the stack, and one for when it is). To maintain flexibility I didn’t force any position naming system, and allowed the user to define how many there will be, and what names to use. The script will also be able to tell you what position it is currently in, and whether or not a transition is active.

Because the Panel component requires the LayoutAnchor component to work, let’s add the RequireComponent tag:

using UnityEngine;
using System;
using System.Collections;
using System.Collections.Generic;

[RequireComponent(typeof(LayoutAnchor))]
public class Panel : MonoBehaviour 
{
	
}

The target positions this panel can hold will be implemented as a Serializable class. I am defining this class within the Panel class because that is the only context in which I intend for it to be used. Also, I can imagine a name like Positions being generic enough that I might want to use it again elsewhere. Note that because the class is serializable you will be able to see it appear in the inspector.

[Serializable]
public class Position
{
	public string name;
	public TextAnchor myAnchor;
	public TextAnchor parentAnchor;
	public Vector2 offset;
	
	public Position (string name)
	{
		this.name = name;
	}
	
	public Position (string name, TextAnchor myAnchor, TextAnchor parentAnchor) : this(name)
	{
		this.myAnchor = myAnchor;
		this.parentAnchor = parentAnchor;
	}
	
	public Position (string name, TextAnchor myAnchor, TextAnchor parentAnchor, Vector2 offset) : this(name, myAnchor, parentAnchor)
	{
		this.offset = offset;
	}
}

I want a way to preconfigure a list of target positions via the inspector. However, I dont want the list to be used anywhere else in code – it is only for the initial implementation. So let’s add this property using the SerializeField tag. Ideally, I want to access Positions via a dictionary where a string (name) points to an instance of Position. This too will be private, but will be used internally in my script. If Unity knew how to show Dictionaries in the inspector I wouldn’t need the list, but alas, that wish has not yet been granted (outside of paying for plugins). Add the following fields (to our Panel class now – not inside the Position class), and then implement them in the Awake method.

[SerializeField] List<Position> positionList;
Dictionary<string, Position> positionMap;
LayoutAnchor anchor;

void Awake ()
{
	anchor = GetComponent<LayoutAnchor>();
	positionMap = new Dictionary<string, Position>(positionList.Count);
	for (int i = positionList.Count - 1; i >= 0; --i)
		AddPosition(positionList[i]);
}

Now let’s add a few properties. I can imagine wanting to know the current position, whether or not we are in a transition, and if so, to be able to access the Tweener. I also want a way to get access to a Position instance using a string name. I will do this using an indexer since I kept the Dicitionary private.

public Position CurrentPosition { get; private set; }
public Tweener Transition { get; private set; }
public bool InTransition { get { return Transition != null; }}

public Position this[string name]
{
	get
	{
		if (positionMap.ContainsKey(name))
			return positionMap[name];
		return null;
	}
}

It’s possible that a user may wish to add or remove Positions dynamically so let’s add a few methods to handle this:

public void AddPosition (Position p)
{
	positionMap[p.name] = p;
}

public void RemovePosition (Position p)
{
	if (positionMap.ContainsKey(p.name))
		positionMap.Remove(p.name);
}

The real purpose of this script though, is to actually move the Panel to one of its specified positions. I will support setting a position based both on a string name and a reference to a position instance.

public Tweener SetPosition (string positionName, bool animated)
{
	return SetPosition(this[positionName], animated);
}

public Tweener SetPosition (Position p, bool animated)
{
	CurrentPosition = p;
	if (CurrentPosition == null)
		return null;

	if (InTransition)
		Transition.easingControl.Stop();

	if (animated)
	{
		Transition = anchor.MoveToAnchorPosition(p.myAnchor, p.parentAnchor, p.offset);
		return Transition;
	}
	else
	{
		anchor.SnapToAnchorPosition(p.myAnchor, p.parentAnchor, p.offset);
		return null;
	}
}

If no Position has been set by the time we reach the Start method, I’ll go ahead and assign the first Position in our list as the default position. I’ll also cause it to Snap into the correct position.

void Start ()
{
	if (CurrentPosition == null && positionList.Count > 0)
		SetPosition(positionList[0], false);
}

Test Drive 2

Now let’s modify our test scene to make use of the Panel component. Remove the AnchorTests script from our Panel object and then add the Panel script. Using the inspector we will pre-configure the first two positions. In most use-cases I would imagine that all UI will have its positions pre-configured and saved as part of a prefab.

 photo PanelComponent_zpssncg6nzh.png

Create and attach another test script named PanelTests to our Panel object.

using UnityEngine;
using System.Collections;

public class PanelTests : MonoBehaviour 
{
	Panel panel;
	const string Show = "Show";
	const string Hide = "Hide";
	const string Center = "Center";

	void Start ()
	{
		panel = GetComponent<Panel>();
		Panel.Position centerPos = new Panel.Position(Center, TextAnchor.MiddleCenter, TextAnchor.MiddleCenter);
		panel.AddPosition(centerPos);
	}

	void OnGUI ()
	{
		if (GUI.Button(new Rect(10, 10, 100, 30), Show))
			panel.SetPosition(Show, true);
		if (GUI.Button(new Rect(10, 50, 100, 30), Hide))
			panel.SetPosition(Hide, true);
		if (GUI.Button(new Rect(10, 90, 100, 30), Center))
		{
			Tweener t = panel.SetPosition(Center, true);
			t.easingControl.equation = EasingEquations.EaseInOutBack;
		}
	}
}

This script shows how to add a third position in code. It also makes a few simple legacy GUI buttons so we can toggle between the positions of the panel and watch it move based on our input. When moving to our dynamically added position, it also shows how to intercept the Tweener and modify it. Play the scene and try moving the panel around!

Quick Note – the use of OnGUI here is only for the sake of a simpler and quicker demo. The equivalent setup using the new UI would have required more steps including extra scene object setup. The use of OnGUI is by no means required as part of the implementation of the components we created. Furthermore, I don’t recommend the use of OnGUI in a live project, and in my tactics project I won’t be using OnGUI anywhere.

Summary

In this lesson we created a few very reusable components to help make the positioning and animation of UI elements much easier. We created individual tests which showed off the component’s functionality, and provided some ideas about how you could use them in code later on. If you watched the Series Intro video, then you will know I will need to show a variety of UI such as stat panels for the attacker and target as well as a menu which allows you to choose an action or skill. I’ve also created a UI which shows dialog to the user for conversations before and after a battle. All of these UI pieces will be able to make great use of our new components, allowing us to save quite a bit of effort in code.

Advertisements

16 thoughts on “Tactics RPG Anchored UI

    1. I still have a bit more content in there to write on. 🙂

      Ultimately, I would like the series to implement a full battle, including a variety of different skills, statuses, equipment, mission objectives, simple A.I. etc. I had stats and skills in the original sample, but I keep going back and forth on the implementation. It’s one thing to get something working for yourself, and a whole different thing to stick it out in the open for everyone to critique. So, there may be a pause in the series after a few more posts, but I will probably keep working on this one for a long time to come.

      Like

      1. I do appreciate that all u’ve shared and u r going to share. Not only I’ve learned a lot about Unity & Game Programming but also the attitude of figure things out. BTW, there r many Unity developers from China love this series. But it’s a pity that I’ve not got any useful feedback about things u wanna discuss. Except one that still confusing about the goodness of combine Monobehaviour and State Mechine.

        Like

      2. Thanks for the kind words, and I am really glad to hear that people are enjoying this series in China. If you are getting questions you can’t answer feel free to ask here and I will try to help. The combination of Monobehaviour and State Machine seemed to be a big issue for people here as well. I went back and forth on the decision myself – I finally chose Monobehaviour because I thought it would be easier for the less experienced users. I got a lot of feedback on that post, and provided some of my own reasons on the design decision on a reddit entry here http://www.reddit.com/r/Unity3D/comments/3833y3/tactics_rpg_tutorial_state_machine/

        Like

  1. I have to ask. But why are you mixing the legacy system with the new UI system’s anchors?

    RectTransforms only work on canvases and the OnGUI functionality doesn’t use that at all.

    Hopefully I’ve just missed something fundamental in what you are trying to achieve.

    AFAIK, you should not mix legacy GUI with the new UI system as they are at crossed purposes.

    Like

    1. Great question Simon. The OnGUI example was merely a short demo to test functionality. I used OnGUI because it is very simple and easy to implement – it can be done entirely through a few lines of code, whereas the same demo created with the new UI would have required more setup (game objects with scripts etc). The Tactics project itself doesn’t use OnGUI anywhere.

      Liked by 1 person

  2. Would you please be so kind to explain what are these Vector2 stand for?
    myOffset , parentOffset, anchorCenter ,myAnchorOffset, myPivotOffset (in the AnchorPosition method) And why is the final position to be parentOffset- myOffset-myAnchorOffset+myPivotOffset+offset. I couldn’t figure it out by myself. Thank you!

    Like

    1. As I mentioned in the paragraph just before that method, even I was a bit confused while trying to work through it. I really just kept trying different things until I got it working. To start down the path toward understanding it, I think I began by imagining a scenario where I could assume a common origin. For example, imagine you have two different sized rectangles where the lower left corner of each is at 0,0. We will imagine the larger of the two rectangles is the parent, and the smaller of the two is the child.

      Next I picked a random idea – like I want to move the small rectangle so that its origin (the lower left) is at the upper right of the large rectangle. I can see that I need to offset the small rectangle by the full width and height of the large rectangle. This value would be the “parentOffset” Vector2. If no other values were referenced and you were only moving the small rectangle’s origin to a position on the large rectangle it would be all you would need.

      Next, I wanted to be able to put any corner of the small rectangle in any corner of the large rectangle. This is the “myOffset” Vector2. Matching the lower left anchor already worked (because lower left GetPosition is 0,0). What if instead of the lower left (origin) of the small rectangle being moved to the upper right of the large rectangle, I decide I want the upper right corner of each to align. In order to achieve this, I see that I can’t move the small rectangle as far – I actually need to subtract the small rectangles full width and height in order to get the upper right corners aligned. This is why I subtract that vector from the parentOffset vector.

      Next, I have to deal with the unfortunate fact that I couldn’t assume a 0,0 (lower-left) origin for the panels (either the parent or the child), and this is where it started to get confusing. The origin could be in the middle, upper right, or anywhere in between! You can start with the parent – this is what the “anchorCenter” is for. I used a linear interpolation from the min to max anchor point using the child’s pivot as the amount to cross. This gave me a new sort of origin but in a “normalized” space (as in a percentage from 0-1).

      To get a real unit-space origin I next needed to multiply the anchorCenter vector against the size of the parent panel’s rect to determine where the origin really was, and this gave me the “myAnchorOffset” vector.

      Finally, because the pivot of the child is also not necessarily at 0,0 then I also need to determine how much of its own size was offset. So I multiplied its own pivot against its own size.

      Now take all of these offsets and add them together until it works right with any configuration and that’s what I was doing in the “pos” vector. Hopefully that clears it up a little.

      Like

  3. I get this error:

    ArgumentNullException: Argument cannot be null.
    Parameter name: key

    Do you know what typically causes this? If so, I can figure out what went wrong.

    Like

    1. A stack trace would be helpful in determining the cause of your problem, but I am guessing that you have added the “Panel” component to a GameObject and not configured it in the inspector. Make sure that you have added entries to the Position List – there is a sample image you can copy from in this post.

      Like

      1. I double checked and it’s configured the exact same way yours is. This is Unity 5.2.3, but I doubt that makes a difference.

        System.Collections.Generic.Dictionary`2[System.String,Panel+Position].set_Item(System.String key, .Position value) (at /Users/builduser/buildslave/mono-runtime-and-classlibs/build/mcs/class/corlib/System.Collections.Generic/Dictionary.cs:155)

        Panel.AddPosition (.Position p) (at Assets/Scripts/Common/UI/Panel.cs:84)

        PanelTests.Start() (at Assets/Scripts/Tests/PanelTests.cs:18)

        So from what I see, it looks like there’s no key set when it tries to add the new position in code via Start() ? In theory what should happen is, it gets added and it shows up in the inspector as a third position in the PositionList when it starts, right?

        Like

      2. Alright, that extra bit helped. It says the problem is on line 84 of the “Panel” script in the Method “AddPosition”. The problem is probably this line:
        positionMap[p.name] = p;
        I suppose you have somehow tried adding a position with a name that is null. Dictionaries don’t support null keys. You can always add some debug code just before it, check for null and if it is, use something like this:
        Debug.Log("Null key name", gameObject);
        Then if you click on the output message in the console window, it will highlight the GameObject in the hierarchy panel which is causing the problem. Hopefully that can help you track down the issue.

        What the code in Start is supposed to be doing is to make sure that the Panel is assigned a Position – it will cause the object it is on to snap into its starting place and will aid in having transitions etc. You have the ability to manually assign a Position such as through an Awake method or by programmatically adding the component and then configuring it yourself. Start runs later, so if nothing has specified the position, then it will just default to assigning itself the first position in its own list. It doesn’t create a new position, it simply uses what you had provided.

        Like

      3. public Position (string name, TextAnchor myAnchor, TextAnchor parentAnchor) : this(name)

        Turns out I forgot the “: this(name)” part when I typed in my copy of this function. Everything works now 🙂

        Liked by 1 person

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s