Search Unity

  1. Welcome to the Unity Forums! Please take the time to read our Code of Conduct to familiarize yourself with the forum rules and how to post constructively.
  2. We have updated the language to the Editor Terms based on feedback from our employees and community. Learn more.
    Dismiss Notice
  3. Join us on November 16th, 2023, between 1 pm and 9 pm CET for Ask the Experts Online on Discord and on Unity Discussions.
    Dismiss Notice

Need help organizing game managers and UI elements

Discussion in 'Scripting' started by PaperMouseGames, Jul 17, 2019.

  1. PaperMouseGames

    PaperMouseGames

    Joined:
    Jul 31, 2018
    Posts:
    434
    Hi there, I'm working on my first big project which is an RPG and as it gets more complex and I learn new things I'm going back and redoing some things I did at the beginning, which I'm fine with, but I'm having a bit of an issue figuring out what to do about one particular aspect of my game; the managers and the UI elements.

    So I posted a while back asking about having multiple managers and if that was a bad practice and most responses I got were encouraging me to keep things separate and I have.

    So for example, one of my managers is the UI Manager and it is a Monobehavior script that sits on a game object in my scene and keeps track of the various canvases in my game turning them on and off depending on the situation.

    I've attached a screenshot of what my UI Manager component looks like on the prefab.

    Capture.PNG


    The issue is that every single UI element it manipulates needs to be individually plugged in using the inspector, and as I add more and more UI elements that need to be manipulated by various managers this is becoming cumbersome.

    It would be one thing if I had to plug them in once, but I have to do this for every scene in my game because I can't plug in the prefabs.

    Is there a better way of doing this? I would image that there must be some more streamlined way of having your managers manipulate UI elements than having to plug in each Canvas and Text element in every single game scene individually. I haven't messed around with making the managers singletons yet, because I just don't know if that's really the solution for all the managers I have, and I need to do more research on singletons since I really don't know much about that.

    One thing I'm experimenting with is multi-scene loading but I haven't figured out everything with that yet. So I think I could have the managers and the UI elements live on a scene that is never unloaded, and then just load the other scenes in additive mode, but I don't know if this will work.

    Anyway thanks a lot for the help in advance!
     
  2. Thibault-Potier

    Thibault-Potier

    Joined:
    Apr 10, 2015
    Posts:
    206
  3. PaperMouseGames

    PaperMouseGames

    Joined:
    Jul 31, 2018
    Posts:
    434
  4. GeorgeCH

    GeorgeCH

    Joined:
    Oct 5, 2016
    Posts:
    222
    You can avoid some of that work by creating an intermediary script (e.g., OpenableCanvas.cs) which raises events whenever a request to open its associated canvas is received.

    Then, have your UI Manager find all instances of the OpenableCanvas class on Awake, store them in an array, and subscribe to the aforementioned events. Whenever an event is raised, the UI manager would loop through the array to close any open canvases that aren't the canvas that's being opened and open the canvas that is.

    With this approach, you won't need to do any inspector assigning whatsoever.

    Code (CSharp):
    1. using UnityEngine.Events;
    2. using UnityEngine.UI;
    3.  
    4. public class OpenableCanvas : MonoBehavior
    5. {
    6.  
    7.     public Canvas Canvas { get; private set; }
    8.  
    9.     public CanvasEvent CanvasOpened { get; private set; } = new UnityEvent();
    10.    
    11.     private void Awake()
    12.     {
    13.         canvas = GetComponent<Canvas>();
    14.     }
    15.  
    16.     public void OpenCanvas()
    17.     {
    18.         CanvasOpened.Invoke(this.canvas);
    19.     }
    20.  
    21.     public void SetActiveStatus(bool status)
    22.     {
    23.         if (canvas.enabled != status)
    24.         {
    25.             canvas.enabled = status;
    26.         }
    27.     }
    28.  
    29. }
    30.  
    31. public class UIManager : MonoBehavior
    32. {
    33.  
    34.     private List<Canvas> canvases;
    35.  
    36.     private void Awake()
    37.     {
    38.    
    39.         canvases = new List<Canvas>();
    40.    
    41.         var _openables = FindObjectsOfType<OpenableCanvas>();
    42.    
    43.         foreach (var openable in _openables )
    44.         {
    45.             canvases.Add(openable.Canvas);
    46.             openable.CanvasOpened.AddListener(OnCanvasOpened);
    47.             openable.CanvasClosed.AddListener(OnCanvasClosed);
    48.         }
    49.     }
    50.  
    51.     private void OnCanvasOpened(Canvas openedCanvas)
    52.     {
    53.         foreach (var canvas in canvases)
    54.         {
    55.             if (canvas != openedCanvas)
    56.             {
    57.                 canvas.SetActiveStatus(false);
    58.             }
    59.        
    60.             else
    61.             {
    62.                 canvas.SetActiveStatus(true);
    63.             }
    64.         }
    65.     }
    66. }
    67.  
    68. public class CanvasEvent : UnityEvent<Canvas>
    69. {
    70.  
    71. }
     
    Last edited: Jul 17, 2019
    PaperMouseGames likes this.
  5. PaperMouseGames

    PaperMouseGames

    Joined:
    Jul 31, 2018
    Posts:
    434

    This is interesting, I guess I could do this same approach for panels and texts that need to be found and manipulated by any given manager as well right?

    The thing I don't really get is, how would you know what exactly you're opening? I mean if I add all these Canvases to an array and then I only want to open the Inventory canvas, how would my manager know which one it is?
     
  6. GeorgeCH

    GeorgeCH

    Joined:
    Oct 5, 2016
    Posts:
    222
    When the event is invoked, it passes with it the canvas that is being opened. Your UI manager then loops through the list of canvases, closes those that aren't the canvas that's being opened, and opens the one that is. You could further expand this approach to assign a unique ID to each canvas, and to then store these as a dictionary in the manager singleton using <string ID, Canvas canvas> key-value pairs.

    Pretty much, yes. I tend to create individual scripts for UI elements within a specific canvas to stick as close as I can to the single responsibility principle as, in my experience, having one controller manage everything quickly turns it into a nightmarish monster that's borderline impossible to debug.

    Code (CSharp):
    1. // This is the base class from which UI components inherit
    2. public abstract class UIComponent : MonoBehaviour
    3. {
    4.     protected bool isInitialized = false;
    5.  
    6.     protected void Refresh() { ReloadUIData(); }
    7.  
    8.     protected abstract void ReloadUIData();
    9.     protected abstract void SubscribeEventListeners();
    10.     protected abstract void UnsubscribeEventListeners();
    11. }
    Code (CSharp):
    1. // This is the canvas-specific class which all components specific to that canvas will inherit from
    2. public abstract class UIProductionSlotComponent : UIComponent
    3. {
    4.     protected ProductionSlot productionSlot;
    5.  
    6.     public virtual void Initialize(ProductionSlot productionSlot)
    7.     {
    8.         this.productionSlot = productionSlot;
    9.         SubscribeEventListeners();
    10.         isInitialized = true;
    11.     }
    12. }
    Code (CSharp):
    1. // This is an example of an actual class - in this case, this class handles displaying the name of the item being produced in the production slot
    2. public class UIProductionSlotName : UIProductionSlotComponent
    3. {
    4.     private TextMeshProUGUI text;
    5.  
    6.     private void Awake()
    7.     {
    8.         text = GetComponent<TextMeshProUGUI>();
    9.     }
    10.  
    11.     public override void Initialize(ProductionSlot productionSlot)
    12.     {
    13.         base.Initialize(productionSlot);
    14.         ReloadUIData();
    15.     }
    16.  
    17.     protected override void ReloadUIData()
    18.     {
    19.         if (productionSlot.GetCurrentProduction() == null)
    20.         {
    21.             text.text = "";
    22.             return;
    23.         }
    24.  
    25.         text.text = productionSlot.GetCurrentProduction().Name;
    26.     }
    27.  
    28.     protected override void SubscribeEventListeners()
    29.     {
    30.         productionSlot.PotionAddedToProduction.AddListener(OnPotionAddedToProduction);
    31.     }
    32.  
    33.     private void OnPotionAddedToProduction(Mixture mixture)
    34.     {
    35.         ReloadUIData();
    36.     }
    37.  
    38.     protected override void UnsubscribeEventListeners()
    39.     {
    40.         productionSlot.PotionAddedToProduction.RemoveListener(OnPotionAddedToProduction);
    41.     }
    42. }
    Code (CSharp):
    1. //Another example - this class handles the production slot's progress bar
    2. public class UIProductionSlotProgressBar : UIProductionSlotComponent
    3. {
    4.     private Image image;
    5.  
    6.     private void Awake()
    7.     {
    8.         image = GetComponent<Image>();
    9.     }
    10.  
    11.     private void Update()
    12.     {
    13.         if (!isInitialized) { return; }
    14.         Refresh();
    15.     }
    16.  
    17.     protected override void ReloadUIData()
    18.     {
    19.         if (productionSlot.GetCurrentProduction() == null)
    20.         {
    21.             if (image.fillAmount != 0f) { image.fillAmount = 0f; }
    22.             return;
    23.         }
    24.  
    25.  
    26.         image.fillAmount = productionSlot.ProductionCycle.GetProgressPercentage();
    27.     }
    28.  
    29.     protected override void SubscribeEventListeners()
    30.     {
    31.         productionSlot.PotionAddedToProduction.AddListener(OnProductionAssignmentChanged);
    32.     }
    33.  
    34.     protected override void UnsubscribeEventListeners()
    35.     {
    36.         productionSlot.PotionAddedToProduction.RemoveListener(OnProductionAssignmentChanged);
    37.     }
    38.  
    39.     private void OnProductionAssignmentChanged(Mixture mixture)
    40.     {
    41.         ReloadUIData();
    42.     }
    43. }
    Code (CSharp):
    1. // And this is the controller that initializes all of the components under its management
    2. public class UIProductionSlotController : MonoBehaviour, IPointerClickHandler
    3. {
    4.     private ProductionSlot productionSlot;
    5.     private UIProductionSlotComponent[] components;
    6.     private ProductionManager productionManager;
    7.  
    8.     private void Awake()
    9.     {
    10.         components = GetComponentsInChildren<UIProductionSlotComponent>();
    11.         productionManager = FindObjectOfType<ProductionManager>();
    12.     }
    13.  
    14.     public void SetControllerData(ProductionSlot productionSlot)
    15.     {
    16.         this.productionSlot = productionSlot;
    17.         foreach (var component in components) { component.Initialize(this.productionSlot); }
    18.     }
    19.  
    20.     public void OnPointerClick(PointerEventData eventData)
    21.     {
    22.         productionManager.SetCurrentlyProductionSlot(this.productionSlot);
    23.     }
    24. }
     
    PaperMouseGames likes this.
  7. PaperMouseGames

    PaperMouseGames

    Joined:
    Jul 31, 2018
    Posts:
    434
    @GeorgeCH

    Thanks a lot for the explanation, I'll definitely experiment with this. I haven't worked much with events before so I'll have to do some more research but this is sounding like it would be a big help, so again, thanks!
     
  8. PaperMouseGames

    PaperMouseGames

    Joined:
    Jul 31, 2018
    Posts:
    434
    So I've been messing around with this and I went through the suggested course but I still don't get it.

    I don't see what the alternative to having to plug everything in to the inspector is. It looks like if I were to make a prefab that had all of my Canvases and Managers(my managers have most of the methods that my buttons in my canvases use, and they hold the info that my Text objects are pulling their text from) then I could plug everything in once and then use that big prefab of all my canvases and managers in future scenes.

    That would work I guess, but after going through the code that GeorgeCH posted, in which he stated I wouldn't need to plug in anything through the inspector, I really don't think I understand it.

    Is there a resource that I could check out that has more info on these ideas?
     
  9. GeorgeCH

    GeorgeCH

    Joined:
    Oct 5, 2016
    Posts:
    222
    Sorry to hear you're still having trouble. Here's a simplified version of what I was suggesting yesterday, without events. In a nutshell, I created a ScreenType enum that lets me select between screen types (in my case, it's a screen containing ingredients and a screen containing potions).

    I then created the UIScreen class with the ScreenType public variable, which I set in the inspector and which lets me designate the type of the screen.

    Finally, my UIManager singleton stores them all in a <ScreenType, UIScreen> dictionary and handles calls to open or close a particular screen.

    Hope it helps - code below.

    Code (CSharp):
    1. public class UIManager : MonoBehaviour
    2. {
    3.     private Dictionary<ScreenType, UIScreen> screens;
    4.  
    5.     private void Start()
    6.     {
    7.         screens = new Dictionary<ScreenType, UIScreen>();
    8.  
    9.         var _screens = FindObjectsOfType<UIScreen>();
    10.         foreach (var screen in _screens)
    11.         {
    12.             screens.Add(screen.ScreenType, screen);
    13.         }
    14.     }
    15.  
    16.     public void OpenScreen(ScreenType targetScreen)
    17.     {
    18.         foreach (var screen in screens)
    19.         {
    20.             if (screen.Value.ScreenType != targetScreen) { screen.Value.Close(); }
    21.             else { screen.Value.Open(); }
    22.         }
    23.     }
    24. }
    Code (CSharp):
    1. public class UIScreen : MonoBehaviour
    2. {
    3.     public ScreenType ScreenType;
    4.     private Canvas canvas;
    5.  
    6.     public bool IsOpen { get { return canvas.enabled; }}
    7.  
    8.     private void Awake()
    9.     {
    10.         canvas = GetComponent<Canvas>();
    11.     }
    12.  
    13.     public void Open()
    14.     {
    15.         if (!canvas.enabled) { canvas.enabled = true; Debug.Log("Opened the screen!"); }
    16.     }
    17.  
    18.     public void Close()
    19.     {
    20.         if (canvas.enabled)
    21.         {
    22.             canvas.enabled = false;
    23.             Debug.Log("Closed the screen!");
    24.         }
    25.     }
    26.  
    27. }
    Code (CSharp):
    1. public enum ScreenType
    2. {
    3.     AlchemyScreen,
    4.     PotionsScreen
    5. }
     
    PaperMouseGames likes this.
  10. PaperMouseGames

    PaperMouseGames

    Joined:
    Jul 31, 2018
    Posts:
    434
    Ohhh I see what you're saying now! Thanks a million for giving a simpler answer, I'm still new to this stuff and as I dug through your previous answers it wasn't making a lot of sense to me because I haven't worked with some of the things you were saying.

    This makes sense though, so you're basically setting up unique IDs with those ScreenType enums, and then when someone wants to open a screen, it needs to pass in the right ID?

    So that answers my question about how you would be able to tell the code which piece to open.

    I assume you could take the same principle and make Text IDs too? Like, if I wanted to display the player HP on the screen, that Text could have a script attached that has a TextType enum which is searched for by the UIManager and then it passes in a string with the player's health?

    So then, my only remaining question I guess is when you need to plug in methods that other managers use to things like buttons.

    So, let's say I have a button on one of my UI canvases that when you click it, it gives the player an item. The method to give the player an item is in my Inventory Manager, not my UI Manager, so I need to plug in my Inventory Manager to the button and then select the right method, but then when I create a new scene and I drop in the prefab of my canvas with that button I need to plug it up again, because it needs to plug into something that is in the scene.

    Is there a way around this? I have a ton of situations like this, and while I don't mind plugging them up initially, it would be a pain to do it in every scene.

    Thanks again, your new explanation is super helpful!
     
  11. GeorgeCH

    GeorgeCH

    Joined:
    Oct 5, 2016
    Posts:
    222
    Yup. :)

    Yes, you could use string-based rather than enum-based keys. However, for the sake of long-term sanity, I'd instead suggest that you write a separate script (PlayerHPText.cs or something) and make it responsible for, basically, displaying the value of the player's HP, without going through the UI Manager. In general, you'll find it easier in the long run if you let the global UI manager handle showing/hiding individual UI elements without getting into managing what and how these UI elements display.

    Is your inventory manager a singleton? The easiest way around it would be to do FindObjectOfType<InventoryManager>() on Awake to establish the reference, then sign up to the Button's onClick event, like so:

    Code (CSharp):
    1. using UnityEngine.UI;
    2.  
    3. public class MyMagicButton : MonoBehavior
    4. {
    5.     private Button button;
    6.     private InventoryManager inventoryManager;
    7.  
    8.     private void Awake()
    9.     {
    10.         button = GetComponent<Button>();
    11.  
    12.         // This is the equivalent of dragging-and-dropping an OnClick reference in the inspector
    13.         button.onClick.AddListener(OnButtonClicked);
    14.        
    15.         inventoryManager = FindObjectOfType<InventoryManager>();
    16.     }
    17.  
    18.     private void OnButtonClicked()
    19.     {
    20.         inventoryManager.GiefEpicItemzPlz();
    21.     }
    22. }
     
    PaperMouseGames likes this.
  12. PaperMouseGames

    PaperMouseGames

    Joined:
    Jul 31, 2018
    Posts:
    434
    Once again, I can't thank you enough for this. This thread has been incredibly helpful to me!

    At the risk of sounding like an idiot I'll admit I honestly just never thought of attaching scripts to the buttons/display texts haha

    My inventory manager is not a singleton, in fact, I don't have any singletons in my game right now, because I'm not very familiar with the concept, and I'm not entirely sure I need one, but I don't know honestly.

    So, just to clarify, you're basically saying, if I have a player health display somewhere on my UI that is made up of a Text element, I should just attach a script to that element and have it display the Player's health (so the script would find the player in the scene, then change it's text to the player's HP).

    Does that still make sense to do if I have a huge amount of texts like that though? You know, player strength, damage, armor, HP, stamina, etc.

    I could make it so the buttons reference a Scriptable Object that holds the value rather than
    FindObjectOfType<Player>()
    on each button I guess.
     
  13. GeorgeCH

    GeorgeCH

    Joined:
    Oct 5, 2016
    Posts:
    222
    That's exactly it. Also, calling FindObjectOfType is ok as long as it's only done in Awake/Start - what you don't want is calling it at runtime.

    Regarding there being too much text: there are ways around that, which would depend very much on how you store the stat information but that's probably a separate thread. I would probably create something along the lines of a StatViewController.cs class and then using enum/string keys to determine which stat it needs to be showing, and attach it to the text object.
     
    PaperMouseGames likes this.
  14. PaperMouseGames

    PaperMouseGames

    Joined:
    Jul 31, 2018
    Posts:
    434
    I'll look into the idea of a stat collector, but for now I have a lot of work to do to refactor my project so that it's getting all the UI pieces it needs to get via code rather than the drag and drop method haha

    Once again, I can't thank you enough, this has been a big learning experience for me and you've been a great help!
     
  15. GeorgeCH

    GeorgeCH

    Joined:
    Oct 5, 2016
    Posts:
    222
    My pleasure! :)