]> git.smokeofanarchy.ru Git - space-station-14.git/commitdiff
Add group for loadouts (#36951)
authorqrwas <aleksandr.vernigora93@gmail.com>
Mon, 16 Jun 2025 09:36:06 +0000 (12:36 +0300)
committerGitHub <noreply@github.com>
Mon, 16 Jun 2025 09:36:06 +0000 (02:36 -0700)
Co-authored-by: ArtisticRoomba <145879011+ArtisticRoomba@users.noreply.github.com>
Content.Client/Lobby/UI/Loadouts/LoadoutContainer.xaml.cs
Content.Client/Lobby/UI/Loadouts/LoadoutGroupContainer.xaml
Content.Client/Lobby/UI/Loadouts/LoadoutGroupContainer.xaml.cs
Content.Client/Lobby/UI/Loadouts/SubLoadoutContainer.xaml [new file with mode: 0644]
Content.Client/Lobby/UI/Loadouts/SubLoadoutContainer.xaml.cs [new file with mode: 0644]
Content.Client/Lobby/UI/Loadouts/ToggleLoadoutButton.xaml [new file with mode: 0644]
Content.Client/Lobby/UI/Loadouts/ToggleLoadoutButton.xaml.cs [new file with mode: 0644]
Content.Shared/Preferences/Loadouts/LoadoutPrototype.cs
Resources/Prototypes/Loadouts/Jobs/Security/head_of_security.yml
Resources/Prototypes/Loadouts/Miscellaneous/trinkets.yml

index 2ab40fb37d40c7ab850810250a3de72f1e5058bf..2264cecd230ebc59cb171a984d98af03bf16b8ee 100644 (file)
@@ -20,6 +20,12 @@ public sealed partial class LoadoutContainer : BoxContainer
 
     public Button Select => SelectButton;
 
+    public string? Text
+    {
+        get => SelectButton.Text;
+        set => SelectButton.Text = value;
+    }
+
     public LoadoutContainer(ProtoId<LoadoutPrototype> proto, bool disabled, FormattedMessage? reason)
     {
         RobustXamlLoader.Load(this);
@@ -54,22 +60,9 @@ public sealed partial class LoadoutContainer : BoxContainer
     protected override void Dispose(bool disposing)
     {
         base.Dispose(disposing);
-
         if (!disposing)
             return;
 
         _entManager.DeleteEntity(_entity);
     }
-
-    public bool Pressed
-    {
-        get => SelectButton.Pressed;
-        set => SelectButton.Pressed = value;
-    }
-
-    public string? Text
-    {
-        get => SelectButton.Text;
-        set => SelectButton.Text = value;
-    }
 }
index 1e3eb14d3fc9bad51dd745cb911e1e15878b83ae..0deb1c82ba787f6b6094b83c533d4e515e1f7034 100644 (file)
@@ -1,10 +1,14 @@
 <BoxContainer xmlns="https://spacestation14.io"
          xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
          Orientation="Vertical">
-    <PanelContainer StyleClasses="AngleRect" HorizontalExpand="True">
-        <BoxContainer Name="LoadoutsContainer" Orientation="Vertical"/>
+    <PanelContainer StyleClasses="AngleRect" HorizontalExpand="True" Margin="5">
+        <BoxContainer Name="LoadoutsContainer" Orientation="Vertical" VerticalExpand="True" HorizontalExpand="True"/>
     </PanelContainer>
     <!-- Buffer space so we have 10 margin between controls but also 10 to the borders -->
-    <Label Text="{Loc 'loadout-restrictions'}" Margin="5 0 5 5"/>
-    <BoxContainer Name="RestrictionsContainer" Orientation="Vertical" HorizontalExpand="True" />
+    <PanelContainer StyleClasses="AngleRect" HorizontalExpand="True" Margin="5">
+        <BoxContainer Orientation="Vertical">
+            <Label Text="{Loc 'loadout-restrictions'}"/>
+            <BoxContainer Name="RestrictionsContainer" Orientation="Vertical" HorizontalExpand="True" />
+        </BoxContainer>
+    </PanelContainer>
 </BoxContainer>
index bc7cfc7f481ea6aad7bc579d9b9c11dc6b538e44..0e18bf1ab5d0449f42b6dcdbcc1b545c1d283ae1 100644 (file)
@@ -1,4 +1,3 @@
-using System.Linq;
 using Content.Shared.Clothing;
 using Content.Shared.Preferences;
 using Content.Shared.Preferences.Loadouts;
@@ -7,12 +6,21 @@ using Robust.Client.UserInterface.Controls;
 using Robust.Client.UserInterface.XAML;
 using Robust.Shared.Player;
 using Robust.Shared.Prototypes;
+using System.Linq;
 
 namespace Content.Client.Lobby.UI.Loadouts;
 
 [GenerateTypedNameReferences]
 public sealed partial class LoadoutGroupContainer : BoxContainer
 {
+    private const string ClosedGroupMark = "▶";
+    private const string OpenedGroupMark = "▼";
+
+    /// <summary>
+    /// A dictionary that stores open groups
+    /// </summary>
+    private Dictionary<string, bool> _openedGroups = new();
+
     private readonly LoadoutGroupPrototype _groupProto;
 
     public event Action<ProtoId<LoadoutPrototype>>? OnLoadoutPressed;
@@ -21,6 +29,7 @@ public sealed partial class LoadoutGroupContainer : BoxContainer
     public LoadoutGroupContainer(HumanoidCharacterProfile profile, RoleLoadout loadout, LoadoutGroupPrototype groupProto, ICommonSession session, IDependencyCollection collection)
     {
         RobustXamlLoader.Load(this);
+        IoCManager.InjectDependencies(this);
         _groupProto = groupProto;
 
         RefreshLoadouts(profile, loadout, session, collection);
@@ -63,32 +72,165 @@ public sealed partial class LoadoutGroupContainer : BoxContainer
         }
 
         LoadoutsContainer.DisposeAllChildren();
-        // Didn't use options because this is more robust in future.
 
-        var selected = loadout.SelectedLoadouts[_groupProto.ID];
+        // Get all loadout prototypes for this group.
+        var validProtos = _groupProto.Loadouts.Select(id => protoMan.Index(id));
+
+        /*
+         * Group the prototypes based on their GroupBy field.
+         * - If GroupBy is null or empty, fallback to grouping by the prototype ID itself.
+         * - The result is a dictionary where:
+         *   - The key is either GroupBy or ID (if GroupBy is not set).
+         *   - The value is the list of prototypes that belong to that group.
+         *
+         * This allows grouping loadouts into sub-categories within the group.
+         */
+        var groups = validProtos
+        .GroupBy(p => string.IsNullOrEmpty(p.GroupBy)
+                         ? p.ID
+                         : p.GroupBy)
+        .ToDictionary(g => g.Key, g => g.ToList());
+
+        foreach (var kvp in groups)
+        {
+            var protos = kvp.Value;
 
-        foreach (var loadoutProto in _groupProto.Loadouts)
+            if (protos.Count > 1)
+            {
+                /*
+                 * Build the list of UI elements for each loadout prototype:
+                 * - For each prototype, create its corresponding LoadoutContainer UI element.
+                 * - Set HorizontalExpand to true so elements properly stretch in layout.
+                 * - Collect all UI elements into a list for further processing.
+                 */
+                var uiElements = protos
+                    .Select(proto =>
+                    {
+                        var elem = CreateLoadoutUI(proto, profile, loadout, session, collection, loadoutSystem);
+                        elem.HorizontalExpand = true;
+                        return elem;
+                    })
+                    .ToList();
+
+                /* 
+                * Determine which element should be displayed first: 
+                * - If any element is currently selected (its button is pressed), use it. 
+                * - Otherwise, fallback to the first element in the list. 
+                * 
+                * This moves the selected item outside of the sublist for better usability, 
+                * making it easier for players to quickly toggle loadout options (e.g. clothing, accessories) 
+                * without having to search inside expanded subgroups. 
+                */
+                var firstElement = uiElements.FirstOrDefault(e => e.Select.Pressed) ?? uiElements[0];
+
+                /*
+                 * Get all remaining elements except the first one:
+                 * - Use ReferenceEquals to ensure we exclude the exact instance used as firstElement.
+                 */
+                var otherElements = uiElements.Where(e => !ReferenceEquals(e, firstElement)).ToList();
+
+                firstElement.HorizontalExpand = true;
+                var subContainer = new SubLoadoutContainer()
+                {
+                    Visible = _openedGroups.GetValueOrDefault(kvp.Key, false)
+                };
+                var toggle = CreateToggleButton(kvp, firstElement, subContainer);
+
+                LoadoutsContainer.AddChild(firstElement);
+                LoadoutsContainer.AddChild(subContainer);
+
+                var subList = subContainer.Grid;
+                foreach (var proto in otherElements)
+                {
+                    subList.AddChild(proto);
+                }
+
+                UpdateToggleColor(toggle, subList);
+            }
+            else
+            {
+                LoadoutsContainer.AddChild(
+                    CreateLoadoutUI(protos[0], profile, loadout, session, collection, loadoutSystem)
+                );
+            }
+        }
+    }
+
+    private ToggleLoadoutButton CreateToggleButton(KeyValuePair<string, List<LoadoutPrototype>> kvp, LoadoutContainer firstElement, SubLoadoutContainer subContainer)
+    {
+        var toggle = new ToggleLoadoutButton
         {
-            if (!protoMan.TryIndex(loadoutProto, out var loadProto))
-                continue;
+            Text = ClosedGroupMark
+        };
 
-            var matchingLoadout = selected.FirstOrDefault(e => e.Prototype == loadoutProto);
-            var pressed = matchingLoadout != null;
+        toggle.Text = subContainer.Visible ? OpenedGroupMark : ClosedGroupMark;
 
-            var enabled = loadout.IsValid(profile, session, loadoutProto, collection, out var reason);
-            var loadoutContainer = new LoadoutContainer(loadoutProto, !enabled, reason);
-            loadoutContainer.Select.Pressed = pressed;
-            loadoutContainer.Text = loadoutSystem.GetName(loadProto);
+        toggle.OnPressed += _ =>
+        {
+            var willOpen = !subContainer.Visible;
+            subContainer.Visible = willOpen;
+            toggle.Text = willOpen ? OpenedGroupMark : ClosedGroupMark;
+            _openedGroups[kvp.Key] = willOpen;
+        };
+
+        firstElement.AddChild(toggle);
+        toggle.SetPositionFirst();
+        return toggle;
+    }
 
-            loadoutContainer.Select.OnPressed += args =>
-            {
-                if (args.Button.Pressed)
-                    OnLoadoutPressed?.Invoke(loadoutProto);
-                else
-                    OnLoadoutUnpressed?.Invoke(loadoutProto);
-            };
+    private void UpdateToggleColor(Button toggle, BoxContainer subList)
+    {
+        var anyActive = subList.Children
+            .OfType<LoadoutContainer>()
+            .Any(c => c.Select.Pressed);
 
-            LoadoutsContainer.AddChild(loadoutContainer);
-        }
+        toggle.Modulate = anyActive
+            ? Color.Green
+            : Color.White;
+    }
+
+    /// <summary>
+    /// Creates a UI container for a single Loadout item.
+    ///
+    /// This method was extracted from RefreshLoadouts because the logic for creating 
+    /// individual loadout items is used multiple times inside that method, and duplicating 
+    /// the code made it harder to maintain.
+    ///
+    /// Logic:
+    /// - Checks if the item is currently selected in the loadout.
+    /// - Checks if the item is valid for selection (IsValid).
+    /// - Creates a LoadoutContainer with the appropriate status (disabled / active).
+    /// - Subscribes to button press events to handle selection and deselection.
+    /// </summary>
+    /// <param name="proto">The loadout item prototype.</param>
+    /// <param name="profile">The humanoid character profile.</param>
+    /// <param name="loadout">The current role loadout for the user.</param>
+    /// <param name="session">The user's session.</param>
+    /// <param name="collection">The dependency injection container.</param>
+    /// <param name="loadoutSystem">The loadout system instance.</param>
+    /// <returns>A fully initialized LoadoutContainer for UI display.</returns>
+    private LoadoutContainer CreateLoadoutUI(LoadoutPrototype proto, HumanoidCharacterProfile profile, RoleLoadout loadout, ICommonSession session, IDependencyCollection collection, LoadoutSystem loadoutSystem)
+    {
+        var selected = loadout.SelectedLoadouts[_groupProto.ID];
+
+        var pressed = selected.Any(e => e.Prototype == proto.ID);
+
+        var enabled = loadout.IsValid(profile, session, proto.ID, collection, out var reason);
+
+        var cont = new LoadoutContainer(proto, !enabled, reason);
+
+        cont.Text = loadoutSystem.GetName(proto);
+
+        cont.Select.Pressed = pressed;
+
+        cont.Select.OnPressed += args =>
+        {
+            if (args.Button.Pressed)
+                OnLoadoutPressed?.Invoke(proto.ID);
+            else
+                OnLoadoutUnpressed?.Invoke(proto.ID);
+        };
+
+        return cont;
     }
 }
diff --git a/Content.Client/Lobby/UI/Loadouts/SubLoadoutContainer.xaml b/Content.Client/Lobby/UI/Loadouts/SubLoadoutContainer.xaml
new file mode 100644 (file)
index 0000000..b825e44
--- /dev/null
@@ -0,0 +1,8 @@
+<PanelContainer Name="SubContainer"
+              xmlns="https://spacestation14.io"
+              xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
+    <BoxContainer Name="SubGridContainer"
+                  Orientation="Vertical"
+                  HorizontalExpand="true"/>
+  
+</PanelContainer>
diff --git a/Content.Client/Lobby/UI/Loadouts/SubLoadoutContainer.xaml.cs b/Content.Client/Lobby/UI/Loadouts/SubLoadoutContainer.xaml.cs
new file mode 100644 (file)
index 0000000..4fa9ee5
--- /dev/null
@@ -0,0 +1,21 @@
+using Robust.Client.AutoGenerated;
+using Robust.Client.UserInterface.Controls;
+
+namespace Content.Client.Lobby.UI.Loadouts;
+
+/// <summary>
+/// A simple container used to group additional loadout UI elements 
+/// that are part of the same GroupBy subgroup.
+/// 
+/// - Used when a loadout group contains multiple prototypes.
+/// - The first prototype is shown directly; the remaining ones are placed inside this container.
+/// - Allows toggling visibility of the subgroup via expandable UI (collapsible behavior).
+/// 
+/// Internally inherits from PanelContainer to allow for border/background styling if needed.
+/// Exposes its internal BoxContainer (SubGridContainer) via the <see cref="Grid"/> property for adding children.
+/// </summary>
+[GenerateTypedNameReferences]
+public sealed partial class SubLoadoutContainer : PanelContainer
+{
+    public BoxContainer Grid => SubGridContainer;
+}
diff --git a/Content.Client/Lobby/UI/Loadouts/ToggleLoadoutButton.xaml b/Content.Client/Lobby/UI/Loadouts/ToggleLoadoutButton.xaml
new file mode 100644 (file)
index 0000000..75898df
--- /dev/null
@@ -0,0 +1,9 @@
+<Button Name="ToggleButton"
+        xmlns="https://spacestation14.io"
+        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+        VerticalExpand="False"
+        HorizontalExpand="False"
+        SetSize="64 64"
+        HorizontalAlignment="Right"
+        VerticalAlignment="Center"
+        Margin="0 0 5 0"/>
diff --git a/Content.Client/Lobby/UI/Loadouts/ToggleLoadoutButton.xaml.cs b/Content.Client/Lobby/UI/Loadouts/ToggleLoadoutButton.xaml.cs
new file mode 100644 (file)
index 0000000..1a118e2
--- /dev/null
@@ -0,0 +1,10 @@
+using Robust.Client.AutoGenerated;
+using Robust.Client.UserInterface.Controls;
+
+namespace Content.Client.Lobby.UI.Loadouts;
+
+/// <summary>
+/// A button that toggles the loadout groups. Needs for override default styles.
+/// </summary>
+[GenerateTypedNameReferences]
+public sealed partial class ToggleLoadoutButton : Button;
index f4ec6986a1bf57b24b7388cb6a4ee0a7e61b96c8..1cde0ad3d68e52a72fd2c07c0f1c2f0d15a331e8 100644 (file)
@@ -13,6 +13,11 @@ public sealed partial class LoadoutPrototype : IPrototype, IEquipmentLoadout
     [IdDataField]
     public string ID { get; private set; } = string.Empty;
 
+    /// <summary>
+    /// A text identifier used to group loadouts.
+    /// </summary>
+    [DataField]
+    public string? GroupBy;
     /*
      * You can either use an existing StartingGearPrototype or specify it inline to avoid bloating yaml.
      */
index a393006d9c5a8e9861468ccb78ac03a1c18b1a36..1f40d5e04437e6246bb67d4bcd53252dd0f70b0a 100644 (file)
   id: HeadofSecurityJumpsuit
   equipment:
     jumpsuit: ClothingUniformJumpsuitHoS
+  groupBy: "jumpsuit"
 
 - type: loadout
   id: HeadofSecurityJumpskirt
   equipment:
     jumpsuit: ClothingUniformJumpskirtHoS
+  groupBy: "jumpskirt"
 
 - type: loadout
   id: HeadofSecurityTurtleneck
   equipment:
     jumpsuit: ClothingUniformJumpsuitHoSAlt
+  groupBy: "jumpsuit"
 
 - type: loadout
   id: HeadofSecurityTurtleneckSkirt
   equipment:
     jumpsuit: ClothingUniformJumpskirtHoSAlt
+  groupBy: "jumpskirt"
 
 - type: loadout
   id: HeadofSecurityFormalSuit
   equipment:
     jumpsuit: ClothingUniformJumpsuitHosFormal
+  groupBy: "jumpsuit"
 
 - type: loadout
   id: HeadofSecurityFormalSkirt
   equipment:
     jumpsuit: ClothingUniformJumpskirtHosFormal
+  groupBy: "jumpskirt"
 
 # Head
 - type: loadout
index e1d45e47841e7407b4705da6655746b54362bd8f..156bade83bf5b4a7c520610478bf05b2cb99405c 100644 (file)
   storage:
     back:
     - Lighter
+  groupBy: "smokeables"
 
 - type: loadout
   id: CigPackGreen
   storage:
     back:
     - CigPackGreen
+  groupBy: "smokeables"
 
 - type: loadout
   id: CigPackRed
   storage:
     back:
     - CigPackRed
+  groupBy: "smokeables"
 
 - type: loadout
   id: CigPackBlue
   storage:
     back:
     - CigPackBlue
+  groupBy: "smokeables"
 
 - type: loadout
   id: CigPackBlack
   storage:
     back:
     - CigPackBlack
+  groupBy: "smokeables"
 
 - type: loadout
   id: CigarCase
@@ -89,6 +94,7 @@
   storage:
     back:
     - CigarCase
+  groupBy: "smokeables"
 
 - type: loadout
   id: CigarGold
   storage:
     back:
     - CigarGold
+  groupBy: "smokeables"
 
 # Pins
 - type: loadout
   storage:
     back:
     - ClothingNeckLGBTPin
+  groupBy: "pin"
 
 - type: loadout
   id: ClothingNeckAllyPin
   storage:
     back:
     - ClothingNeckAllyPin
+  groupBy: "pin"
 
 - type: loadout
   id: ClothingNeckAromanticPin
   storage:
     back:
     - ClothingNeckAromanticPin
+  groupBy: "pin"
 
 - type: loadout
   id: ClothingNeckAsexualPin
   storage:
     back:
     - ClothingNeckAsexualPin
-
-- type: loadout
-  id: ClothingNeckAroacePin
-  storage:
-    back:
-    - ClothingNeckAroacePin
+  groupBy: "pin"
 
 - type: loadout
   id: ClothingNeckBisexualPin
   storage:
     back:
     - ClothingNeckBisexualPin
+  groupBy: "pin"
 
 - type: loadout
   id: ClothingNeckGayPin
   storage:
     back:
     - ClothingNeckGayPin
+  groupBy: "pin"
 
 - type: loadout
   id: ClothingNeckIntersexPin
   storage:
     back:
     - ClothingNeckIntersexPin
+  groupBy: "pin"
 
 - type: loadout
   id: ClothingNeckLesbianPin
   storage:
     back:
     - ClothingNeckLesbianPin
+  groupBy: "pin"
 
 - type: loadout
   id: ClothingNeckNonBinaryPin
   storage:
     back:
     - ClothingNeckNonBinaryPin
+  groupBy: "pin"
 
 - type: loadout
   id: ClothingNeckPansexualPin
   storage:
     back:
     - ClothingNeckPansexualPin
-
-- type: loadout
-  id: ClothingNeckPluralPin
-  storage:
-    back:
-    - ClothingNeckPluralPin
+  groupBy: "pin"
 
 - type: loadout
   id: ClothingNeckOmnisexualPin
   storage:
     back:
     - ClothingNeckOmnisexualPin
+  groupBy: "pin"
 
 - type: loadout
   id: ClothingNeckGenderqueerPin
   storage:
     back:
     - ClothingNeckGenderqueerPin
-
-- type: loadout
-  id: ClothingNeckGenderfluidPin
-  storage:
-    back:
-    - ClothingNeckGenderfluidPin
+  groupBy: "pin"
 
 - type: loadout
   id: ClothingNeckTransPin
   storage:
     back:
     - ClothingNeckTransPin
+  groupBy: "pin"
 
 - type: loadout
   id: ClothingNeckAutismPin
   storage:
     back:
     - ClothingNeckAutismPin
+  groupBy: "pin"
 
 - type: loadout
   id: ClothingNeckGoldAutismPin
   storage:
     back:
     - ClothingNeckGoldAutismPin
+  groupBy: "pin"
+
+- type: loadout
+  id: ClothingNeckAroacePin
+  storage:
+    back:
+    - ClothingNeckAroacePin
+  groupBy: "pin"
+
+- type: loadout
+  id: ClothingNeckPluralPin
+  storage:
+    back:
+    - ClothingNeckPluralPin
+  groupBy: "pin"
+
+- type: loadout
+  id: ClothingNeckGenderfluidPin
+  storage:
+    back:
+    - ClothingNeckGenderfluidPin
+  groupBy: "pin"
 
 # Towels
 - type: loadout
   storage:
     back:
     - TowelColorWhite
+  groupBy: "towels"
 
 - type: loadout
   id: TowelColorSilver
   storage:
     back:
     - TowelColorSilver
+  groupBy: "towels"
 
 - type: loadout
   id: TowelColorGold
   storage:
     back:
     - TowelColorGold
+  groupBy: "towels"
 
 - type: loadout
   id: TowelColorLightBrown
   storage:
     back:
     - TowelColorLightBrown
+  groupBy: "towels"
 
 - type: loadout
   id: TowelColorGreen
   storage:
     back:
     - TowelColorGreen
+  groupBy: "towels"
 
 - type: loadout
   id: TowelColorDarkBlue
   storage:
     back:
     - TowelColorDarkBlue
+  groupBy: "towels"
 
 - type: loadout
   id: TowelColorOrange
   storage:
     back:
     - TowelColorOrange
+  groupBy: "towels"
 
 - type: loadout
   id: TowelColorLightBlue
   storage:
     back:
     - TowelColorLightBlue
+  groupBy: "towels"
 
 - type: loadout
   id: TowelColorPurple
   storage:
     back:
     - TowelColorPurple
+  groupBy: "towels"
 
 - type: loadout
   id: TowelColorRed
   storage:
     back:
     - TowelColorRed
+  groupBy: "towels"
 
 - type: loadout
   id: TowelColorGray
   storage:
     back:
     - TowelColorGray
+  groupBy: "towels"
 
 - type: loadout
   id: TowelColorBlack
   storage:
     back:
     - TowelColorBlack
+  groupBy: "towels"
 
 - type: loadout
   id: TowelColorDarkGreen
   storage:
     back:
     - TowelColorDarkGreen
+  groupBy: "towels"
 
 - type: loadout
   id: TowelColorMaroon
   storage:
     back:
     - TowelColorMaroon
+  groupBy: "towels"
 
 - type: loadout
   id: TowelColorYellow
   storage:
     back:
     - TowelColorYellow
+  groupBy: "towels"
 
 - type: loadout
   id: TowelColorMime
   storage:
     back:
     - TowelColorMime
+  groupBy: "towels"