-using System.Linq;
using Content.Shared.Clothing;
using Content.Shared.Preferences;
using Content.Shared.Preferences.Loadouts;
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;
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);
}
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;
}
}
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
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"