Supporting files for VG3 quests are part of a single archive that you can download here.
Players will be able to load saved characters from a Character Select scene (which we will build in an upcoming step). Players can reach the Character Select screen through the Home scene. The Home screen will act as a content hub connecting all of our developer exercises. Building the Home scene is the focus of this step.
Save any leftover changes in the currently open scene and start a new scene from the File > New Scene menu. Create a scene from the "Basic (URP)" template and save it as "Home" in the /Assets/Scenes/ folder.
Ensure that the Home scene is the currently open scene for editing.
To match the black background of our other scene, inspect Main Camera. Set the Camera's Environment properties to a Solid Color of black.
Create a Canvas from the Hierarchy's + > UI menu. In the Canvas Scaler component, switch Mode to "Scale with Screen Size" and set a reference resolution of 1280 by 720 for high definition. Match the Height of the screen to maintain the look of our UI across various widescreen aspect ratios.
Within the Canvas game object, create a UI Panel named "Panel - Home" to organize the content of this screen. "Panel - Home" should be centered with a size of 800 by 600. Adjust the Image component's Color to remove all of the transparency from this panel, so that it is fully opaque.
Add a UI Text (TextMeshPro) object as a child of "Panel - Home" and name it "Text - Title". To implement a title header content style, position "Text - Title" anchored and pivoted upper-left with 20 pixels of spacing from the left and 20 pixels down from the top. The text content should read "My Game" with a bold font sized 36. The text color should be black.
Our Home screen will present the player with a list of buttons. To achieve this menu composition, create an empty child game object of "Panel - Home" named "Content". This game object should be anchored to the top and stretch the width of its parent with 15 pixels of spacing on both left and right. It should also be pivoted upper-left and positioned 75 pixels down from the top.
We'll add a Vertical Layout Group component to the Content object to auto-arrange our buttons. The Vertical Layout Group should have left and right padding of 10, top and bottom padding of 25, and a spacing of 10. Align the children to the Upper Left. We want "Control Child Size" and "Child Force Expand" enabled for Width, so that our buttons will always be the full width of the menu.
To make sure our "Content" object is always big enough to show any number of buttons in our menu, we'll add a Content Size Fitter component. Content Size Fitter influences the sizing of the "Content" object and not the children. With this in mind, Horizontal Fit should be Unconstrained because we have set our width manually, while Vertical Fit should be Preferred Size because we want to automatically adjust the height of "Content" based on his children.
Our Menu will start with only two buttons. A "Create New Character" button will lead to the Character Builder scene from our prior assignments. An "Edit Characters" button will lead to our upcoming Character Select scene for loading save files.
Create two UI Buttons (TextMeshPro) as children of the "Content" object, each with a height of 50. The first button should be named "Button - Character Creator" with a button text of "Create a New Character". The second button should be named "Button - Edit Characters" with a button text of "Edit Characters".
Because of the Vertical Layout Group component in the parent, these buttons should automatically form a nice menu composition.
To make our menu buttons functional, create a MenuHome.cs file in the /Assets/Scripts/ folder.
Attach MenuHome to the "Panel - Home" game object in the scene.
MenuHome.cs
using UnityEngine; using UnityEngine.SceneManagement; public class MenuHome : MonoBehaviour { // Methods public void GoToScene(string sceneName) { if(!string.IsNullOrWhiteSpace(sceneName)) { SceneManager.LoadScene(sceneName); } } }
The sole purpose of this screen is to jump to other scenes, which is why our C# file contains just one "GoToScene" function. The function is public so that we can access it with our buttons' click events. These click events should send in a string for the requested scene name (though we make sure an empty scene hasn't been requested accidentally). If a scene name has been supplied, we attempt to load the scene using the SceneManager class.
Inspect "Button - Character Creator" and add a button click event that references the "Panel - Home" object. From the drop-down, choose the MenuHome.GoToScene function and supply a function argument requesting the "CharacterBuilder" scene.
Inspect "Button - Edit Character" and add a button click event that references the "Panel - Home" object. From the drop-down, choose the MenuHome.GoToScene function and supply a function argument requesting the "CharacterSelect" scene.
From the menu bar, open File > Build Profiles. Make sure the Scene List includes Home and CharacterBuilder. (You can drag scenes from the Project tab into the Build Profiles tab if anything is missing.)
Testing the game at this point should reveal that you can use the Home menu to travel to the CharacterBuilder scene by clicking the "Create a New Character" button. For now, the Home button on the CharacterBuilder screen doesn't work, and the CharacterSelect scene does not yet exist.
Now that we have the Home screen as a hub to reach other scenes, we can build the Character Select scene to load characters we have previously saved.
Save any leftover changes in the currently open scene and start a new scene from the File > New Scene menu. Create a scene from the "Basic (URP)" template and save it as "CharacterSelect" in the /Assets/Scenes/ folder.
Ensure that the CharacterSelect scene is the currently open scene for editing.
To match the black background of our other scene, inspect Main Camera. Set the Camera's Environment properties to a Solid Color of black.
Create a Canvas from the Hierarchy's + > UI menu. In the Canvas Scaler component, switch Mode to "Scale with Screen Size" and set a reference resolution of 1280 by 720 for high definition. Match the Height of the screen to maintain the look of our UI across various widescreen aspect ratios.
Within the Canvas game object, create a UI Panel named "Panel - Character Select" to organize the content of this screen. This panel should be centered with a size of 800 by 600. Adjust the Image component's Color to remove all of the transparency from this panel, so that it is fully opaque.
Add a UI Text (TextMeshPro) object as a child of "Panel - Character Select" and name it "Text - Title". To implement a title header content style, position "Text - Title" anchored and pivoted upper-left with 20 pixels of spacing from the left and 20 pixels down from the top. The text content should read "Select a Character" with a bold font sized 36. The text color should be black. Increase the width as necessary to prevent the text from wrapping.
Create a UI Button (TextMeshPro) object as another child of the Panel. Name this new child "Button - Home" with button text Home. The button should be anchored and pivoted to the upper-right with a position 20 pixels left of the right edge and 20 pixels down from the top. Size the button 160 by 50. Ensure the button text is bold with a font size of 25.
We'll code this button to change scenes later, but now is a good time to remember that we can't load scenes if they are missing from the Build Profile's Scene List. From the menu bar, open File > Build Profiles. Add CharacterSelect to the Scene List.
Our Character Select screen will present the player with a scrollable view containing one permanent button followed by an unknown quanitity of additional buttons. The permanent button lets players create a new character. The rest of the subsequent buttons will each represent an existing save file. That means the button list in this menu will be dynamic, and we won't know how many buttons to create until the scene is running. We will use a similar vertical menu layout as the Home scene.
Create a UI Scroll View named "Scroll View - Characters" as a child of the panel. We will size the scroll view to take up most of the viewable menu space. Configure the Rect Transform to stretch in all directions with 75 pixels of spacing from the top and 15 pixels of spacing from the left/right/bottom. Configure the Scroll Rect component to only scroll vertically.
Most of the scroll view hierarchy structure is automatically configured by default, but we will need to customize the Content wrapper to fit our content. Find the Content game object inside of the scroll view's viewport hierarchy. To automatically arrange our menu buttons into a vertical list, add a Vertical Layout Group component to the Content game object. Set left and right padding to 10, top and bottom padding to 25, and spacing to 10. Turn on the settings for controlling the child width and forcing the child width to expand. Make sure the rest of the settings are unchecked.
We'll also add a Content Size Fitter to make sure our Content view expands tall enough to include any number of buttons required to portray all our save files. On Content Size Fitter set Horizontal Fit to unconstrained and Vertical Fit to Preferred Size.
Create a UI Button (TextMeshPro) named "Button - Create" as a child of Content. Give the button a height of 50 with the text "Create a New Character".
The next object we create will become a prefab that is reused for each character save file. In addition to loading that save file, we want the player to be able to delete character files they no longer want. We won't be creating just one button per save file, but rather a row of buttons for various file operations.
Create an empty game object named "Button - Character File" as the next sibling within Content. Set it's height to 50 to stay consistent with the prior button.
The file operations we can perform include loading the file and deleting the save file. Because deleting the file is a destructive act, it is a best UX practice to ask the player to confirm the deletion or cancel. To cover these interactions, we'll need four buttons divided into two steps.
To seperate our interactions into two steps, we'll utilize more empty child game objects to organize our buttons. Create two additional empty game objects as children of "Button - Character File" named "Main Buttons" and "Confirmation Buttons". Both of these objects will have the same inspector configuration: They should stretch to fit the size of their parent with 0 spacing in all directions. They should each have a Horizontal Layout Group with a spacing of 10 to automatically place the buttons in a row. Enable controlling the child height and force the height to expand.
Within "Main Buttons" create two children UI Button (TextMeshPro) objects: "Button - Load" should have a width of 573 with the text "Load: {NAME}". The text "{NAME}" is a placeholder, and we will program it to show the character name during gameplay. Because character names can be unpredictable and may cause the button text to overflow, enable Auto Size with a Max of 24 on the Button text.
Also create a second child named "Button - Delete" with a width of 150 and the text "Delete".
If a player clicks "Delete", we will show a second step of confirmation buttons. To reveal this content, we will hide the "Main Buttons" game object and activate the "Confirmation Buttons" object. Until we program this, we have to show and hide these objects by hand. For now, manually deactivate "Main Buttons" so that we can see the content we are making inside the "Confirmation Buttons" object.
Create a Text Mesh Pro UI object as a child of "Confirmation Buttons" named "Text - Confirm" using a width of 410. Set the text to "Confirm Character Deletion:" with bold font size 27 and horizontal right and vertical middle alignment. Use a black font color for readability.
Create another child UI Button (TextMeshPro) named "Button - Confirm Delete" with a width of 150 with the text "DELETE" in bold white font. Set the button background color to red to emphasize the destructive nature of this UI choice.
Create another child UI Button (TextMeshPro) named "Button - Cancel" with a width of 150 and the text "Cancel".
There is a lot of functionality conveyed in all these menus. Let's begin hooking up code starting with the character file buttons. Create a new C# file named MenuCharacterSelectFile.cs and attach it to the "Button - Character File" game object. Also attach a Character component which we will use to hold the character data from the save file associated with this UI button. Our initial code will focus on the juggling of active game objects to create the interactivity of various menus turning on and off.
MenuCharacterSelectFile.cs
using UnityEngine; using TMPro; public class MenuCharacterSelectFile : MonoBehaviour { // Outlets public GameObject mainContent; public GameObject confirmContent; public TMP_Text textName; // Configuration public Character character; // Methods void Awake() { CancelDelete(); } public void DeleteCharacter() { mainContent.SetActive(false); confirmContent.SetActive(true); } public void CancelDelete() { mainContent.SetActive(true); confirmContent.SetActive(false); } }
The outlets and configuration sections provides several variables that will allow us to manipulate the UI and track character data. Hook up the various UI elements and components to their corresponding outlet properties.
The DeleteCharacter and CancelDelete functions are click events that toggle the UI between different steps in the delete confirmation process. (We wouldn't want to immediately delete a save file at the slightest misclick.) To help protect against a UI designer accidentally leaving the incorrect menu active, we also call CancelDelete during the Awake event, which will reset the UI to a default state where "Main Buttons" is active and "Confirmation Buttons" is hidden.
Hook up the "DeleteCharacter" function as the click event for the delete button.
Hook up the "CancelDelete" function as the click event for the cancel button.
Testing the game at this step should reveal that you can flip through a couple variations of the UI by clicking the delete and cancel buttons.
Besides toggling through various buttons, the overall goal of our UI is to load a character or delete a save file. We should think of this in two halves: The code that creates an interactive UI experience for the player, and code that executes on the resulting user requests. It is advantageous to organize code in this manner because it helps our functionality to be reusable and scalable regardless of its UI presentation.
The intention of MenuCharacterSelectFile.cs is the former. It specifically is only to handle the visual UI interactions of a single entry in our character list. More complex functionality such as save data and character management will be written in a separate file that manages the menu overall.
To organize our programming and allow separate code files to work together, we want MenuCharacterSelectFile.cs to have OnSelectCharacter and OnDeleteCharacter events that other code can "subscribe" to. In a way, we want our parent menu to be notified when something happens with its child buttons. For example, when OnSelectCharacter happens because a button was clicked, we want some other function in a different file to also execute. We can reference functions from other files using delegates.
MenuCharacterSelectFile.cs
public class MenuCharacterSelectFile : MonoBehaviour { // Events public delegate void IntEvent(int index); public IntEvent OnSelectCharacter; public IntEvent OnDeleteCharacter; // Outlets public GameObject mainContent; public GameObject confirmContent; public TMP_Text textName;
Delegate types are a new coding approach we explore in this step. Delegates are a technique for referencing functions. In the same way that variables store data for subsequent operations, you can imagine delegates as a sort of variable that allows us to collect one or more functions for later use. In this code, we define a delegate type named IntEvent described as a function that returns void and accepts an integer as a parameter. (Line 7) The reason we want to receive an integer as part of our event is for us to know which Character ID is relevant to the request. We then create two instances of this delegate type named OnSelectCharacter and OnDeleteCharacter that accept functions matching the function signature of our delegate type. Notice how these instances follow typical variable syntax. (Lines 8-9)
MenuCharacterSelectFile.cs
// Methods void Awake() { CancelDelete(); } public void DeleteCharacter() { mainContent.SetActive(false); confirmContent.SetActive(true); } public void CancelDelete() { mainContent.SetActive(true); confirmContent.SetActive(false); } public void ConfirmDelete() { if(OnDeleteCharacter != null) { OnDeleteCharacter(character.id); } } public void LoadCharacter() { if(OnSelectCharacter != null) { OnSelectCharacter(character.id); } } }
A common delegate implementation is the callback function. For our character select UI, each of our character file buttons will have delegates representing events such as OnSelectCharacter and OnDeleteCharacter. By themselves, these delegates do nothing, but our parent menu will register callback functions in each of these delegates. Registered callback functions do not trigger right away and instead wait until the delegate event is invoked. When a button is clicked, the button first processes its own click logic to figure out what character ID is associated with that save file. This ID is then passed to the assigned callback functions when the delegate is invoked. (Line 36 and Line 42)
Hook up the "ConfirmDelete" function as the click event for the confirm delete button.
Hook up the "LoadCharacter" function as the click event for the load button.
In larger games, this technique becomes especially useful because multiple functions can be stored in a delegate. For example, you could have several objects "subscribe" to a player's OnGameOver event to run several functions when the player loses.
We will see how to register callback functions soon. For now, it is important to recognize that the UI for an individual save file is complete. Our delegate events are prepared, even if other functionality is not yet ready. This modularity is one of the benefits of this coding technique.
Prefab the "Button - Character File" game object by dragging it into your /Assets/Prefabs/ folder in the Project tab. Delete "Button - Character File" from the scene.
Notice that "Button - Character File" no longer appears in the scene before execution.
We'll create MenuCharacterSelect.cs to manage the overall character select menu beyond the individual save file buttons completed in prior steps.
Create and attach MenuCharacterSelect.cs to "Panel - Character Select".
MenuCharacterSelect.cs
using UnityEngine; using UnityEngine.SceneManagement; public class MenuCharacterSelect : MonoBehaviour { // Outlets // Methods public void GoHome() { SceneManager.LoadScene("Home"); } public void CreateNewCharacter() { SceneManager.LoadScene("CharacterBuilder"); } }
Two of the buttons simply switch to different scenes. Hook up the "GoHome" function as the click event for the home button.
Hook up the "CreateNewCharacter" function as the click event for the create button.
Testing the game should show that clicking both home and create buttons switches to the appropriate scenes.
We need to add a few more helper functions to Character.cs to handle our Character-related save and load operations.
GetCharactersIds loops through all files in our character save directory and returns a list of character IDs available to load.
Character.cs
public static List<int> GetCharactersIds() { List<int> result = new List<int>(); if(Directory.Exists(GenerateDirectoryPath())) { DirectoryInfo directoryInfo = new DirectoryInfo(GenerateDirectoryPath()); foreach(FileInfo file in directoryInfo.GetFiles("*.json")) { // Get ID from filename string fileNameNoExtension = file.Name.Replace(".json", ""); // Protect against filenames that are not IDs if(int.TryParse(fileNameNoExtension, out int id)) { result.Add(id); } } } return result; }
LoadFromFileByPath loads a character save file based on a requested file path.
Character.cs
public void LoadFromFileByPath(string path) { if(File.Exists(path)) { StreamReader sr = new StreamReader(path); LoadFromJson(sr.ReadToEnd()); sr.Close(); } }
LoadFromFileById loads a character save file based on a requested character ID.
Character.cs
public void LoadFromFileById(int _id) { if(_id > 0) { string path = GenerateFilePath(_id); LoadFromFileByPath(path); } }
DeleteCharacter deletes a character save file based on a requested character id.
Character.cs
public static void DeleteCharacter(int characterIndex) { string path = GenerateFilePath(characterIndex); if(characterIndex > 0 && File.Exists(path)) { File.Delete(path); } }
Return to MenuCharacterSelect.cs to create the outlets we need to add dynamic content to the UI.
MenuCharacterSelect.cs
public class MenuCharacterSelect : MonoBehaviour { // Outlets public GameObject characterButtonPrefab; public Transform contentWrapper; // Methods
Assign the appropriate scene game object as the content wrapper.
Using the character helper methods from prior steps and our new outlets, we can create an instance of our button prefab for every character save file. RebuildList avoids deleting the existing "Create a New Character" button at the top when clearing and building the list of character file buttons. We call RebuildList from the Start event to fill in our content as soon as the menu appears.
MenuCharacterSelect.cs
// Methods void Start() { RebuildList(); } void RebuildList() { // Clear existing save file buttons // DON'T DELETE THE FIRST BUTTON at i = 0 for(int i = 1; i < contentWrapper.childCount; i++) { Destroy(contentWrapper.GetChild(i).gameObject); } // Create a button for each character save file foreach(int characterId in Character.GetCharactersIds()) { GameObject characterButton = Instantiate(characterButtonPrefab, contentWrapper); MenuCharacterSelectFile characterMenu = characterButton.GetComponent<MenuCharacterSelectFile>(); characterMenu.character.LoadFromFileById(characterId); characterMenu.textName.text = "Load: " + characterMenu.character.playerName; } }
Testing the game at this point should reveal that the menu fills with buttons for any characters you may have already saved, but the "Load" and "Confirm Delete" buttons do not yet work.
Deleting a character's save file requires that we create the callback function DeleteCharacter and assign it to the OnDeleteCharacter delegate of each of our character buttons.
MenuCharacterSelect.cs
void DeleteCharacter(int characterIndex) { Character.DeleteCharacter(characterIndex); RebuildList(); }
MenuCharacterSelect.cs
void RebuildList() { // Clear existing save file buttons // DON'T DELETE THE FIRST BUTTON at i = 0 for(int i = 1; i < contentWrapper.childCount; i++) { Destroy(contentWrapper.GetChild(i).gameObject); } // Create a button for each character save file foreach(int characterId in Character.GetCharactersIds()) { GameObject characterButton = Instantiate(characterButtonPrefab, contentWrapper); MenuCharacterSelectFile characterMenu = characterButton.GetComponent<MenuCharacterSelectFile>(); characterMenu.character.LoadFromFileById(characterId); characterMenu.textName.text = "Load: " + characterMenu.character.playerName; characterMenu.OnDeleteCharacter += DeleteCharacter; } }
Testing the game at this point should reveal that you can PERMANENTLY delete a particular character save file. (Be careful that you don't test this feature on a character you actually want to keep.)
We must make some changes to MenuCharacterCreator so that it knows how to handle loading a requested character instead of starting with the default character.
A public static idToLoad property allows other screens to assign values to this variable and request a particular character, even when the Character Creator Menu isn't loaded yet. A character ID of 0 represents no character to load and causes the menu to start with the default character.
We also add a nameInputField property so we can restore player names when loading character data.
MenuCharacterCreator.cs
public Character character; public TMP_InputField nameInputField; // Configuration public EmoteConfig emoteConfig; public static int idToLoad = 0; // State Tracking
Assign the character name's input field to the new outlet variable.
A LoadCharacter function responds to the value of idToLoad. If the value is 0, we load default character values, otherwise we load the requested character data and assign its values throughout the UI.
MenuCharacterCreator.cs
void LoadCharacter() { if(idToLoad > 0) { character.LoadFromFileById(idToLoad); idToLoad = 0; // Reset to 0 so we don't repeatedly load the same file // Show Character Data in UI nameInputField.text = character.playerName; foreach(CharacterModel model in characters) { model.SetColor(character.color); SetColorUI(character.color); } // Recreate saved emotes foreach(Emote emote in character.emotes) { menuEmoteBuilder.CreateEmoteButton(emote.frameIndex, emote.iconIndex); } } else { // Load default colors from existing scene model foreach(CharacterModel model in characters) { character.color = model.GetColor(); SetColorUI(character.color); } } }
LoadCharacter is called during the Start event so that any idToLoad requests are processed right away. Various other lines of the Start event are also tweaked to use any loaded character data insted of scene defaults.
MenuCharacterCreator.cs
void Start() { LoadCharacter(); SwitchCharacter((int)character.model); // Read default character color if(characters.Length > 0) { // (All characters have the same color) character.color = characters[0].GetColor(); } SetColorUI(character.color); ShowScreen(Screen.Character); }
While we're still in the MenuCharacterCreator file, let's also hook up the Home button.
MenuCharacterCreator.cs
using System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.UI; using TMPro; using UnityEngine.SceneManagement;
MenuCharacterCreator.cs
public void GoHome() { SceneManager.LoadScene("Home"); }
Back in MenuCharacterSelect, loading character data works similarly to deleting it. We will set up a callback function LoadCharacter and assign it to the OnSelectCharacter delegate for each character button.
MenuCharacterSelect.cs
void LoadCharacter(int characterIndex) { MenuCharacterCreator.idToLoad = characterIndex; SceneManager.LoadScene("CharacterBuilder"); }
MenuCharacterSelect.cs
void RebuildList() { // Clear existing save file buttons // DON'T DELETE THE FIRST BUTTON at i = 0 for(int i = 1; i < contentWrapper.childCount; i++) { Destroy(contentWrapper.GetChild(i).gameObject); } // Create a button for each character save file foreach(int characterId in Character.GetCharactersIds()) { GameObject characterButton = Instantiate(characterButtonPrefab, contentWrapper); MenuCharacterSelectFile characterMenu = characterButton.GetComponent<MenuCharacterSelectFile>(); characterMenu.character.LoadFromFileById(characterId); characterMenu.textName.text = "Load: " + characterMenu.character.playerName; characterMenu.OnDeleteCharacter += DeleteCharacter; characterMenu.OnSelectCharacter += LoadCharacter; } }
Testing the game at this point should allow you to freely navigate through all 3 Home, Character Builder, and Character Select screens. Selecting a character to load should take you to the Character Builder with all character details and emotes restored.
Play your scene and ensure that all your features and interactions perform as demonstrated throughout the tutorial.
SAVE any open files or scenes and close Unity to ensure any temp files are finalized.
Submit your assignment for grading following the instructions supplied for your particular classroom.