Maps are important for many games as they help guide the player towards key initiatives and if done right, help engross them further into the world. There are two types of maps – full map and a mini map. This tutorial will walk through how to implement both using UI Toolkit.
This tutorial builds off of the compass system that was built in Create a compass and objectives with uGUI. You can skip the tutorial if you are only interested in building a map with UI Toolkit.
Learning Outcomes
This tutorial will cover advanced scenarios of UI Toolkit and how you can create a full and mini map. By the end, you will learn:
- A practical workflow for building a reusable map that can be displayed in full or mini mode.
- Basic masking in UI Toolkit.
- Advanced stylesheet techniques using complex selectors.
- Animating style properties in C# with UI Toolkit’s experimental animation features.
Prerequisites
- You should have Unity 2021.2 or later to follow along with this tutorial.
- This tutorial assumes you have basic knowledge of Unity and UI Toolkit.
This tutorial will work with earlier versions of Unity but will require adjustments. For example, earlier versions did not support a center pivot point and thus require a different structure for animations. Check out the UI Builder release notes for more information.
Getting Started
The starter project features a compass and objective system that is part of the Create a compass and objectives with uGUI tutorial. This tutorial can be done independently. However, check out the tutorial if you are interested in learning more on how that was created.
This tutorial builds on top of a demo scene that is included in the starter project. You can download the starter project by:
- Clone and/or download the GitHub repository.
- Navigate to the create-a-map-system-with-ui-toolkit\projects\map-system-starter folder in Unity.
You will see several folders folders in Assets/WUG. The project comes with a demo scene, models, animations, sprites, textures, prefabs, and materials. You’ll use assets from the Sprites folder throughout the tutorial. The rest is included for the demo scene, which is where you will work in.
Open the scene by navigating to Assets/WUG/Scenes/Demo. Push play and use WASD to move the player around and walk across the bridge. You can walk over the bow or quiver to pick them up. Lets get started adding the rest of the logic to add a fully functioning map with UI Toolkit.
If you are using version of Unity earlier than 2021.1 then you will need to install UI Toolkit Runtime and/or UI Builder. Check out the UI Builder release notes for more information.
Design the full map
This UI document will have one map element that you will display in two different ways – full screen and mini. This will make maintenance quite simple, which is always a win! In this section you’ll create the full screen map. To start, setup your scene:
- Right click on the Hierarchy and pick UI Toolkit > UI Document. Name it Map.
The UI Document component comes with three properties:
- Panel Settings: A series of properties that are used to determine how the UI is displayed.
- Source Asset: The UI document (UXML) that should be displayed.
- Sort Order: The order that the UI document will show up in relation to other UI Documents attached to the same parent.
Next you need something to display. Go to Window > UI Toolkit > UI Builder. In UI Builder, go to File > Save As and name it Map. Click on Map.uxml in the hierarchy and select Match Game View, located in the Inspector.
When working with UI Builder it can be helpful to make it full screen. Double click on the tab to switch in/out of full screen mode.
Create the styles
Next, add a new style sheet by clicking the + button in the Stylesheet section and picking Create New USS . Name it MapStyles.
Select Map.uss to pull up the Inspector properties. In the Selector field, put the following (include the period): .root-container-full and push Create New USS Selector. This will create a new style class for you to reference on VisualElements. Click on .root-container-full and set the following properties:
- Flex > Grow: 1
- Align > Align Items: center
- Align > Justify Content: Middle
- Margin & Padding > Margin: 5px
You’ll need to create two more styles:
- Selector: .map-container
- Size > Width: 50%
- Size > Height: 90%
- Display > Overflow: Hidden
- Align > Align Items: Center
- Align > Justify Content: Middle
Last one:
- Selector: .map-img
- Position > Position: Absolute
- Align > Align Items: Center
- Align > Justify Content: Middle
- Size > Width: 115%
- Size > Height: 100%
- Background > Image > Sprite: game_map
- Background > Scale Mode: scale-and-crop
Go to File > Save to save your changes.
Add the VisualElements
Next, drag a new VisualElement to the Hierarchy and name it Container. Drag the .root-container-full style onto container to set it.
Add a new VisualElement as a child to container, and set the following properties:
- Name: Map
- Style: .map-container
Add a new VisualElement as a child to Map, and set the following properties:
- Name: Image
- Style: .map-img
Your UI builder should now look like this:
Configure the UI Document component
Hop back into your Unity scene to setup the UI Document component. Set the Source Asset to the Map.uxml. You should now see the map in the middle of your Game view. Double click on the PanelSettings reference to open up the file. Change Scale Mode to Scale with Screen Size.
Try changing the game resolution and/or window size before setting Scale Mode to Scale with Screen Size to see the impact of the different settings.
Design the mini map
If you were to push play and walk around, you’d be stuck with the map in your way. While you could code the ability to toggle it on/off (and will), it really should default in mini-map mode. This will be largely handled via styles. Instead of using UI Builder, you can add the code directly to the file. Open up MapStyles.uss in your IDE of choice, and add the following code:
.root-container-mini { flex-grow: 1; align-items: flex-end; margin-left: 5px; margin-right: 5px; margin-top: 5px; margin-bottom: 5px; justify-content: flex-start; } .root-container-mini #Map { width: 150px; height: 150px; border-left-width: 2px; border-right-width: 2px; border-top-width: 2px; border-bottom-width: 2px; border-left-color: rgba(255, 255, 255, 255); border-right-color: rgba(255, 255, 255, 255); border-top-color: rgba(255, 255, 255, 255); border-bottom-color: rgba(255, 255, 255, 255); } .root-container-mini #Image { position: absolute; -unity-background-scale-mode: scale-and-crop; width: 512px; height: 512px; }
.root-container-mini
is pretty straight forward – it’s changing the alignment of the children to be on the top left of the screen, instead of center. Aside from that, all other properties match .root-container-full. The intention is that the container VisualElement will only ever have one style – .root-container-full
for when you are displaying the full map, or .root-container-mini
to display the mini map.
The next two styles are where the magic is. If you are not familiar, in CSS land prefixing with # says, “I want to attach this to all elements that match this name”. If you combine it with a class, such as .root-container-mini #Map
, you are saying:
- First, get every element that has the class
.root-container-mini
. - Next, find every child of those elements that match the name
Map
. - Then, apply the style to those children only.
In other words, .root-container-mini #Map
and .root-container-mini #Image
will only be applied if the container VisualElement has .root-container-mini
on it.
Don’t worry if this doesn’t make a ton of sense – you’ll see this in action once you write code to swap out the styles.
Hop over to UI Builder and replace .root-container-full
with .root-container.mini
on container.
Your scene should look like this:
Code the map modes
The player will be able to toggle between the full sized map and the mini map with the “M” button. Create a new script called MapController
and add it as a component to the Map game object. Add the following code:
private VisualElement _root; private bool IsMapOpen => _root.ClassListContains("root-container-full"); void Start() { _root = GetComponent<UIDocument>().rootVisualElement.Q<VisualElement>("Container"); } void Update() { if (Input.GetKeyDown(KeyCode.M)) { ToggleMap(!IsMapOpen); } } private void ToggleMap(bool on) { _root.EnableInClassList("root-container-mini", !on); _root.EnableInClassList("root-container-full", on); }
Here’s what this code is doing:
Start
is getting a reference to the Container VisualElement that you created and setting it to_root
.IsMapOpen
checks whetherroot-container-full
class exists. If it does, then you can safely assume that the map is open.Update
is monitoring for the user to push the M key and once done, it’ll callToggleMap
and pass the opposite of the current map state.ToggleMap
uses the bool to enable/disable theroot-container-mini
androot-container-full
classes, based on whether the map is now open or closed.
EnableInClassList
is a nice helper method that lets you pass in a bool to represent the current state. It’s the equivalent of using RemoveFromClassList(string)
and AddToClassList(string)
.
Run your game and push the M key. You should now see the map toggle on and off.
Design the player icon
Maps are a lot more useful if you can easily tell where important bits of information are! In this tutorial, you’ll add is a representation of the player. Hop back over the USS file and add three new styles:
.player-container { width: 10px; height: 10px; } .player-cone { background-image: url('project://database/Assets/Sprites/playerCone.png?fileID=21300000&guid=0474f680ae98d884a8bf7ed7a1836229&type=3#playerCone'); width: 80px; height: 60px; -unity-background-scale-mode: scale-to-fit; position: absolute; top: -54px; left: -35px; -unity-background-image-tint-color: rgba(255, 255, 255, 0.29); align-items: center; justify-content: flex-end; } .player-arrow { width: 10px; height: 10px; background-image: url('project://database/Assets/Sprites/player_arrow.png?fileID=2800000&guid=3e620c7728093c44e811a73f5fb61640&type=3#player_arrow'); -unity-background-image-tint-color: rgb(255, 50, 73); top: 4px; }
Next, hop back over to UI Builder and add a VisualElement as a child of Image. Set the following:
- Name: Player
- Style: player-container
Add another VisualElement as a child of Player. Set the following:
- Name: Cone
- Style: player-cone
Add one final VisualElement as a child of Cone. Set the following:
- Name: Arrow
- Style: player-arrow
Your UI should now look like this:
Code the player icon
Now for the semi-tricky part – aligning the player’s world position to the UI position. You can calculate this fairly easily by taking the X and Z position of the player GameObject and mapping them to the X and Y position of the player VisualElement, factoring in a multiplier to account for a slight difference in positioning.
Head back over to MapController and add the following code:
public GameObject Player; [Range(1,15)] public float miniMultiplyer = 5.3f; [Range(1, 15)] public float fullMultiplyer = 7f; private VisualElement _playerRepresentation; void Start() { //original code above _playerRepresentation = _root.Q<VisualElement>("Player"); } private void LateUpdate() { var multiplyer = IsMapOpen ? fullMultiplyer : miniMultiplyer; _playerRepresentation.style.translate = new Translate(Player.transform.position.x * multiplyer, Player.transform.position.z * -multiplyer, 0); _playerRepresentation.style.rotate = new Rotate( new Angle(Player.transform.rotation.eulerAngles.y)); }
public class MapController : MonoBehaviour { public GameObject Player; [Range(1, 15)] public float miniMultiplyer = 5.3f; [Range(1, 15)] public float fullMultiplyer = 7f; private VisualElement _root; private VisualElement _playerRepresentation; private bool IsMapOpen => _root.ClassListContains("root-container-full"); void Start() { _root = GetComponent<UIDocument>().rootVisualElement.Q<VisualElement> ("Container"); _playerRepresentation = _root.Q<VisualElement>("Player"); } void Update() { if (Input.GetKeyDown(KeyCode.M)) { ToggleMap(!IsMapOpen); } } private void LateUpdate() { var multiplyer = IsMapOpen ? fullMultiplyer : miniMultiplyer; _playerRepresentation.style.translate = new Translate(Player.transform.position.x * multiplyer, Player.transform.position.z * -multiplyer, 0); _playerRepresentation.style.rotate = new Rotate( new Angle(Player.transform.rotation.eulerAngles.y)); } private void ToggleMap(bool on) { _root.EnableInClassList("root-container-mini", !on); _root.EnableInClassList("root-container-full", on); } }
The interesting part of this code is in LateUpdate
, which is used instead of Update
to ensure that the map is updated after everything else finishes. The Translate style property is used to move the position of the visual element based on the position of the player GameObject. The Rotate style property is used to, well, rotate the player. It takes a new angle based on the GameObject’s Y value.
New style properties were introduced in Unity 2021.2, which means the new recommended way for moving and rotating elements is through the Translate and Rotate style properties. Two more new properties are scale and transform-origin. You can read more here.
You’ll probably need a different multiplier for your game. With your map open, move your character to a landmark that is easy to find. Then, alter the Multiplier slider until the visual element is in the right spot.
Hop over to Unity and assign the Player GameObject to the Player property. Make sure mini map multiplyer has 5.3 and the full map one is 7. Push play. You should see the Player icon move away from the house. Walk around and rotate a bit to see it work.
Code the mini-map movement
This is great, but the mini-map should move with the player. In this project, the mini-map will lock on the bounds of the image, making it so that it stops scrolling. The easiest way to do this is to clamp the left/right and top/bottom positions.
Do this now by adding the following code to MapController
:
private VisualElement _mapContainer; private VisualElement _mapImage; void Start() { //original code above _mapImage = _root.Q<VisualElement>("Image"); _mapContainer = _root.Q<VisualElement>("Map"); } private void LateUpdate() { //original code above if (!IsMapOpen) { var clampWidth = _mapImage.worldBound.width / 2 - _mapContainer.worldBound.width / 2; var clampHeight = _mapImage.worldBound.height / 2 - _mapContainer.worldBound.height / 2; var xPos = Mathf.Clamp(Player.transform.position.x * -Multiplyer, -clampWidth, clampWidth); var yPos = Mathf.Clamp(Player.transform.position.z * Multiplyer, -clampHeight, clampHeight); _mapImage.style.translate = new Translate(xPos, yPos, 0); } else { _mapImage.style.translate = new Translate(0, 0, 0); } }
public class MapController : MonoBehaviour { public GameObject Player; [Range(1, 15)] public float miniMultiplyer = 5.3f; [Range(1, 15)] public float fullMultiplyer = 7f; private VisualElement _root; private VisualElement _playerRepresentation; private VisualElement _mapContainer; private VisualElement _mapImage; private bool IsMapOpen => _root.ClassListContains("root-container-full"); void Start() { _root = GetComponent<UIDocument>().rootVisualElement.Q<VisualElement> ("Container"); _playerRepresentation = _root.Q<VisualElement>("Player"); _mapImage = _root.Q<VisualElement>("Image"); _mapContainer = _root.Q<VisualElement>("Map"); } void Update() { if (Input.GetKeyDown(KeyCode.M)) { ToggleMap(!IsMapOpen); } } private void LateUpdate() { var multiplyer = IsMapOpen ? fullMultiplyer : miniMultiplyer; _playerRepresentation.style.translate = new Translate(Player.transform.position.x * multiplyer, Player.transform.position.z * -multiplyer, 0); _playerRepresentation.style.rotate = new Rotate( new Angle(Player.transform.rotation.eulerAngles.y)); if (!IsMapOpen) { var clampWidth = _mapImage.worldBound.width / 2 - _mapContainer.worldBound.width / 2; var clampHeight = _mapImage.worldBound.height / 2 - _mapContainer.worldBound.height / 2; var xPos = Mathf.Clamp(Player.transform.position.x * -Multiplyer, -clampWidth, clampWidth); var yPos = Mathf.Clamp(Player.transform.position.z * Multiplyer, -clampHeight, clampHeight); _mapImage.style.translate = new Translate(xPos, yPos, 0); } else { _mapImage.style.translate = new Translate(0, 0, 0); } } private void ToggleMap(bool on) { _root.EnableInClassList("root-container-mini", !on); _root.EnableInClassList("root-container-full", on); } }
Here’s what the new code in LateUpdate is doing:
- Lines 17-20: Calculate the clamp width based on the map image and map container element widths. Since the map image is placed in the center, you only need half of the total width.
- Lines 22-25: Calculates the X and Y positions, using the player’s position. Mathf.Clamp ensures that the map image will not move out of bounds.
- Line 27: Applies the new X/Y position to the map image element.
If you want your map to keep scrolling then ditch all of the code in the if statement and add this line: _mapImage.style.translate = new Translate(Player.transform.position.x * -Multiplyer, Player.transform.position.z * Multiplyer, 0);
Push play and move the player to the end of the world (do not run over it)! You should see the map move until you near the bounds, then it’ll stop scrolling.
Dim while the player moves
If the player moves while the map is in full screen mode, then the image tint will dim to 50% alpha. Add the following code to MapController
:
private bool _mapFaded; public bool MapFaded { get => _mapFaded; set { if (_mapFaded == value) { return; } Color end = !_mapFaded ? Color.white.WithAlpha(.5f) : Color.white; _mapImage.experimental.animation.Start( _mapImage.style.unityBackgroundImageTintColor.value, end, 500, (elm, val) => { elm.style.unityBackgroundImageTintColor = val; }); _mapFaded = value; } } void LateUpdate() { // original code above MapFaded = IsMapOpen && PlayerController.Instance.IsMoving }
public class MapController : MonoBehaviour { public GameObject Player; [Range(1, 15)] public float miniMultiplyer = 5.3f; [Range(1, 15)] public float fullMultiplyer = 7f; private VisualElement _root; private VisualElement _playerRepresentation; private VisualElement _mapContainer; private VisualElement _mapImage; private bool IsMapOpen => _root.ClassListContains("root-container-full"); private bool _mapFaded; public bool MapFaded { get => _mapFaded; set { if (_mapFaded == value) { return; } Color end = !_mapFaded ? Color.white.WithAlpha(.5f) : Color.white; _mapImage.experimental.animation.Start( _mapImage.style.unityBackgroundImageTintColor.value, end, 500, (elm, val) => { elm.style.unityBackgroundImageTintColor = val; }); _mapFaded = value; } } void Start() { _root = GetComponent<UIDocument>().rootVisualElement.Q<VisualElement> ("Container"); _playerRepresentation = _root.Q<VisualElement>("Player"); _mapImage = _root.Q<VisualElement>("Image"); _mapContainer = _root.Q<VisualElement>("Map"); } void Update() { if (Input.GetKeyDown(KeyCode.M)) { ToggleMap(!IsMapOpen); } } private void LateUpdate() { var multiplyer = IsMapOpen ? fullMultiplyer : miniMultiplyer; _playerRepresentation.style.translate = new Translate(Player.transform.position.x * multiplyer, Player.transform.position.z * -multiplyer, 0); _playerRepresentation.style.rotate = new Rotate( new Angle(Player.transform.rotation.eulerAngles.y)); if (!IsMapOpen) { var clampWidth = _mapImage.worldBound.width / 2 - _mapContainer.worldBound.width / 2; var clampHeight = _mapImage.worldBound.height / 2 - _mapContainer.worldBound.height / 2; var xPos = Mathf.Clamp(Player.transform.position.x * -Multiplyer, -clampWidth, clampWidth); var yPos = Mathf.Clamp(Player.transform.position.z * Multiplyer, -clampHeight, clampHeight); _mapImage.style.translate = new Translate(xPos, yPos, 0); } else { _mapImage.style.translate = new Translate(0, 0, 0); } MapFaded = IsMapOpen && PlayerController.Instance.IsMoving } private void ToggleMap(bool on) { _root.EnableInClassList("root-container-mini", !on); _root.EnableInClassList("root-container-full", on); } }
LateUpdate
will evaluate whether the map is open and the player is moving, then passes the value to the MapFaded
property. MapFaded
will first check to make sure that the value is new, and if so it’ll calculate the new alpha – which is either 100% (not faded) or 50% (faded). The new value will be passed to the Start
method of the UI Toolkit animation. Each frame that the animation is running for will set the current value to the elements Background Image Tint Color style property.
You can animate just about any style property and include easing to smooth out the animation. Check out the documentation for more information. An alternative to the UI Toolkit built in animation is DOTween. Check out the Animate runtime progress bars with UI Toolkit for a guide on how to use DOTween with UI Toolkit.
Pull up the full map and move the player to see it dim. Stop moving and it should go back to normal.
Great content! Keep up the good work!
Thank you! 😀