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. Dismiss Notice

Equippable item design and architecture

Discussion in 'General Discussion' started by loxagos_snake, Feb 10, 2022.

  1. loxagos_snake

    loxagos_snake

    Joined:
    Mar 8, 2015
    Posts:
    8
    Hello folks! I'm currently prototyping the main mechanics a rather complex project, and I'm constantly bumping into design issues. I'm going to mention an example that has me scratching my head for days.


    Requirements

    The main mechanics are basically an FPS game, in a survival setting. I will focus on a very specific requirement instead of boring you with the details. The player has the ability to equip items from the inventory, which could belong in various categories -- let's say, for example, weapons, grenades and consumables. When I pop the inventory UI open, I can right-click on an item and select 'Equip' from a context menu. The item viewmodel is then parented under a 'mount' GameObject which, in turn, is parented under the camera, and I should be able to 'use' it, whatever that means for the specific item (shoot, throw, or consume). Closest example I can think of right now is Minecraft: depending on what you have equipped, clicking the left mouse button could either attack with a sword, or eat a steak. It's important that some of these actions require a long press, which will come up later.

    Design Challenges

    The current architecture is this: there's an inheritance hierarchy of ItemBase ScriptableObjects that only contain data for the items, as well as the appropriate prefab/sprite references for the viewmodel and inventory icons respectively. Weapons have a WeaponController component that takes the weapon data, accepts input and orchestrates other behavioral components on the same GameObject (shooting, reloading, projectile/hitscan). The two main dependencies for weapons are the FPS camera (for raycasting) and an InputManager singleton that is responsible for managing input contexts and events (using the New Input System).

    The main issue is that I want to be able to handle different types of equippable objects in an abstract way. For example, if I press left mouse click, I want the equipped GameObject to do whatever it should be doing, be it firing, throwing or consuming. My initial instinct was to slap an IEquippedItem interface with a UseItem() method. However, this is complicated by the fact that some of these actions are dependent on different input scenarios (semi-auto guns should fire once even when held, auto should stop when input stops, grenades can be cooked) and there are item types that wouldn't need some of the input (i.e. consumables can't be reloaded), thus turning the interface into a secondary input manager and breaking interface segregation at the same time.

    Simplest way to solve this is by having the equippable GameObjects have their own controllers, which accepts input directly and still encapsulates the object's behavior. For instance, my weapons are made of a WeaponController that orchestrates modular components for weapon behaviors on the same GO. When they are instantiated by the inventory system, they are placed under the mount, the component gets activated and the appropriate references (input manager, FPS camera) are grabbed with GameObject.Find().

    As quick as this is, I don't like it at all. For starters, I want to avoid using Find as much as possible, but I can't think of another way to inject the dependency that isn't sketchy -- I don't want to use an external DI framework. I could always slap a component on the mount that just passes the references to the newly-spawned item, but then not all items need a camera for raycasting (i.e. consumables). What bothers me even more though, is that I now have X kinds of different items that should ideally be treated abstractly, even if their implementation varies, yet they now do things their own way.

    Conclusion

    This seemingly easy-to-solve design problem is giving me a headache. I have searched far and wide for an efficient way to handle it, but most resources don't bother with it. Most tutorials are for limited use cases or demonstration purposes, so they either haphazardly write the functionality in a huge PlayerController script, or make judicious use of reflection (I do use Instantiate as well, but that's because I don't have a pooling system yet).
     
  2. neginfinity

    neginfinity

    Joined:
    Jan 27, 2013
    Posts:
    13,321
    The least painful option is to use GetComponentInParent when the object is enabled, and not GameObject.Find. Also, inheritance hierarchies are best avoided.

    So, for example, you have Weapon and you have Combatant where Combatant is a universal class used by both Enemies and Player, except that Player implements PlayerController while Enemy implements AiController which is attached as a component. Weapon is attached to a Combatant.

    You can abstract the weapon behavior away completely, by implementing a "trigger pressed"/"trigger released" events fired by Combatant. Those could be actual C# events, in which case Weapon would search for a combatant it is attached to within OnEnable, using GetComponentInParent and then subscribe/unsubscribe to them. Because GetComponentInParent does not search the whole scene, it is fairly fast. Or you could make Combatant search through attached weapons, and fire public method on them something like "setTriggerState(bool enabled)". Be aware that GetComponent* functions work with interfaces, so you could implement something like IWeaponEventSource, have combatant implement it, and have weapon find it using GetComponentInParent. It is also possible to use messages, i.e. SendMessage* functions but this way you'll be relying on function anems, and notifiers without proper signature. Meaning if you decide to rename a message, you'd need to hunt it in your code.

    The downside of that is that AI would have to be aware how to fire signals to the weapon properly, but you could just implement some sort of "AiWeaponHint" class which will store whether it is single shot, auto, full auto, grenade or anything else. Or "WeaponConfiguration" which could list damage, effective range, clip size, clip type and so on and have AI work with that.

    That's the rough idea.
     
  3. loxagos_snake

    loxagos_snake

    Joined:
    Mar 8, 2015
    Posts:
    8
    I agree that it's generally better to use composition over inheritance, but I wonder why it would be problematic in this case. The inheritance hierarchy I'm using is only for the item data classes, and according to my designs, it's not going to go over two levels deep. Subclasses simply inherit general properties from the ItemBase (ID, item name, prefabs/icons etc.) and define some of their own (weapon damage, healing amount etc.). It's also useful in the inventory: I can utilize the Item subtype to, for example, build a context menu dynamically. Is it a Weapon? Include an 'Equip' button. Consumable? Include 'Use' button etc.

    I rarely ever use inheritance in actual gameplay objects, prefering to build functionality with components.

    I think you just described one of my possible solutions, but this still leaves a question behind: what if the equipped item isn't a weapon? With the Combatant approach, the 'firing trigger' methods could set bools that determine whether the gun should be firing or not, letting the Fire Mode component decide whether it's full auto or semi auto. However I also need to have other input behaviors like reloading, which wouldn't necessarily be implemented by, say, a grenade or a medkit.

    I tried this as a way to inject the camera dependencies, but I run into two problems. First, the weapon is actually two levels deeper than the camera (... -> Camera -> Mount -> WeaponObject) so it needs a hardcoded recursive parent call, followed by GetComponentInParent. This seems a bit...wobbly. If I ever change the camera rig for some reason, it might break. But that's an easy bug to fix. The problem is that there's a bit of a race condition going on: the inventory system instantiates the weapon and parents it to the Mount. If I call GetComponentInParent from the WeaponController's Awake() method, it fires right before setting the parent, so I get an exception because naturally, the weapon hasn't been parented yet at the time of the Awake() call. I could query the object about to be equipped and assign the camera from the inventory, but this doesn't seem like a concern that should be handled by the inventory.
     
  4. neginfinity

    neginfinity

    Joined:
    Jan 27, 2013
    Posts:
    13,321
    The object decides what it does when user presses a key, pretty much. You can forward more signals, and then implement more complex behaviors.

    OR you can implement "ItemConfig" which would be a single class describing ALL possible items, and implement "itemType" there. ItemType:RangedWeapon, ItemType:MeleeWeapon, ItemType:Consumable, ItemType:Grenade, etc. And conditionally treat different behaviors differently. Unless you want the user to be able wield health kit as a grenade, this would suffice.

    In my opinion, weapon would be attached to the player, and "Weapon hands" attached to the camera could be a decorator and not the actual weapon being used. But that's debatable and depends on whether your game 1st or 3rd person. I wouldn't be linking camera to weapon, to be honest.

    This can be solved by using a prefab/GameObject.Instantiate (..., Transform parent);
    https://docs.unity3d.com/ScriptReference/Object.Instantiate.html

    Alternatively you can move the initialization code away from awake, and into part that attaches the weapon. Have it query a weapon company and fire a notifier on it. Then act within that notifier. "OnWeaponAttached()". You can also use send message for the same effect.
     
  5. loxagos_snake

    loxagos_snake

    Joined:
    Mar 8, 2015
    Posts:
    8
    It's first person, and the 'weapon hands' depend on the camera motion. I hear you about attaching the weapon functionality to the player, but we're running into the 'what if it isn't a weapon' issue again. Not only that, each weapon prefab has a series of modular components that control its behavior, which I would have to be dumping onto the player GameObject every time I swapped a weapon. I think these are better encapsulated on a weapon GameObject, and the player controller can still easily access it through an IEquippable property.

    I know I'm being pedantic here, but the inventory script still needs a reference to a camera it should ideally know nothing about, so it can pass it as a parent.
     
  6. neginfinity

    neginfinity

    Joined:
    Jan 27, 2013
    Posts:
    13,321
    I think rather than "is it a weapon" the question is "is it a 1st person item that affects visible hands and their animation".

    Not necessarily.

    For example, it could be done this way. When you drag an item into equipment slot, it fires some sort of "onEquipped" notification. That propagates to player script, and player script decides if it affects visual representation or not.

    From there you can parametrize the behavior.
    A visible item can affect visual hands (armor) appearance.
    It can affect visual representation of held item. (different grenades have different mesh)
    It can replace hand animation. (unusual weapon will have different moveset)

    So they don't need to talk directly to the camera. They may need to talk to weapon hands.
     
  7. Stardog

    Stardog

    Joined:
    Jun 28, 2010
    Posts:
    1,886
    You could just ignore all of the SOLID stuff and embrace the fact that functions are first-class in C#, meaning they can be stored anywhere. The shotgun fire function does not need to be in a shotgun class/type.

    Also, the type of firing (auto, single, delayed) shouldn't relate to your input. The function that runs can do what it wants. The input will be either pressed or not. It shouldn't care about what happens after.

    If you break it down to just data, and the data communicates everything, it will become easier. The following code is unfiished, but it's just an example. The interesting part is that there are no types or inheritance.

    Note: I've ommited some initializing/null check code for space.
    Code (csharp):
    1. public static class ItemSystem
    2. {
    3.     // Can be loaded in Awake from ScriptableObjects
    4.     public static Dictionary<string, (string Name, string PrimaryFireID, string SecondaryFireID, GameObject PrefabViewModel)> ItemDB;
    5.  
    6.     // InventoryUI display names/descriptions for actions
    7.     public static Dictionary<string, string> ItemActionDB
    8.     {
    9.         ["use"] = "Use",
    10.         ["drop"] = "Drop",
    11.         ["equip"] = "Equip"
    12.     };
    13.  
    14.     // InventoryUI can filter (System.Linq Where function) this when clicking on an item, then get data from ItemActionDB
    15.     public static List<(string ItemID, string ActionID)> ItemActions
    16.     {
    17.         ("shotgun, "equip"),
    18.        ("shotgun", "drop"),
    19.        ("apple", "use"),
    20.        ("apple", "drop")
    21.    };
    22.  
    23.    // Stores all functions for all items
    24.    public static Dictionary<string, Action> UseFunctions;
    25.    public static Dictionary<string, Action> PrimaryFires;
    26.    public static Dictionary<string, Action> SecondaryFires;
    27.  
    28.    // Add items from here to make it easier, so you don't have to manually initialize/add to dictionaries.
    29.    public static AddItem(string id, string name, List<string> actions, Action onUse = null, Action onPrimaryFire = null, Action onSecondaryFire = null)
    30.    {
    31.        UseFunctions.Add(onUse);
    32.        PrimaryFires.Add(onPrimaryFire);
    33.        SecondaryFires.Add(onSecondaryFire);
    34.  
    35.        foreach(var actionID in actions) ItemActions.Add((id, actionID));
    36.    }
    37. }
    Code (csharp):
    1. public class Game : MonoBehaviour
    2. {
    3.     void Awake()
    4.     {
    5.         ItemSystem.AddItem("apple", "Apple",
    6.             actions: new List<string>
    7.             {
    8.                 "use",
    9.                 "drop"
    10.             },
    11.             onUse: () => Debug.Log("Used from inventory."),
    12.             onPrimaryFire: () => Debug.Log("Eat apple function. Get player hunger and increase it.")
    13.         );
    14.  
    15.         ItemSystem.AddItem("shotgun", "Shotgun",
    16.             onPrimaryFire: () =>
    17.             {
    18.                 Debug.Log("Shotgun unique primary fire function. Instantiate prefab, play audio, etc.");
    19.             },
    20.             onSecondaryFire: FireProjectile
    21.         );
    22.     }
    23.  
    24.     void FireProjectile() => Debug.Log("Reusable projectile function.");
    25. }
    Code (csharp):
    1. public class PlayerController : MonoBehaviour
    2. {
    3.     public string EquippedID = "shotgun";
    4.     public Transform ItemMountT;
    5.     public InventoryView InventoryView;
    6.     private GameObject _viewModelInstance;
    7.  
    8.     void Awake()
    9.     {
    10.         InventoryView.OnItemActionClicked += (itemID, itemActionID) =>
    11.         {
    12.             switch(itemActionID)
    13.             {
    14.                 case "use":
    15.                     ItemSystem.UseFunctions[itemID]?.Invoke();
    16.                     break;
    17.                 case "equip":
    18.                     Equip(itemID);
    19.                     break;
    20.             }
    21.         }
    22.     }
    23.  
    24.     void Update()
    25.     {
    26.         if (Controls.PrimaryFire)
    27.         {
    28.              PrimaryFire(EquippedID);
    29.         }
    30.     }
    31.  
    32.     void PrimaryFire(string itemID)
    33.     {
    34.         ItemSystem.PrimaryFires[EquippedID]?.Invoke();
    35.     }
    36.  
    37.     void Equip(string itemID)
    38.     {
    39.         var (_, _, _, PrefabViewModel) = ItemSystem.ItemDB[itemID];
    40.  
    41.         EquippedID = itemID;
    42.  
    43.         Destroy(_viewModelInstance);
    44.         _viewModelInstance = Instantiate(PrefabViewModel, ItemMountT);
    45.     }
    46. }
     
  8. neginfinity

    neginfinity

    Joined:
    Jan 27, 2013
    Posts:
    13,321
    Stuff like this should rely on constants or readonlies and shouldn't be typed in directly. Otherwise you'll be in a world of pain when you try to rename anything or make a typo.

    Same goes for the switch/cases.

    Actually if you're using switch/cases, it means this should probably be an enum.