From b5d8def08ae01c8334f488b8a253d7ae34d14ff7 Mon Sep 17 00:00:00 2001 From: Crotalus Date: Sat, 11 Jul 2026 22:34:53 +0200 Subject: [PATCH] Add filtering / sorting support to EOR player manifest (#39165) * Add support for filter/sort in EOR player manifest Now using GridContainer with filtering and sortable columns * Fix linting error * Increase sprite size from 24px -> 32px * Reviews * forgor to inject --------- Co-authored-by: AJCM-git --- .../RoundEnd/RoundEndSummaryUIController.cs | 2 +- .../RoundEnd/RoundEndSummaryWindow.cs | 597 ++++++++++++++---- .../round-end/round-end-summary-window.ftl | 9 + 3 files changed, 471 insertions(+), 137 deletions(-) diff --git a/Content.Client/RoundEnd/RoundEndSummaryUIController.cs b/Content.Client/RoundEnd/RoundEndSummaryUIController.cs index bb85d98353..0e8f5c5017 100644 --- a/Content.Client/RoundEnd/RoundEndSummaryUIController.cs +++ b/Content.Client/RoundEnd/RoundEndSummaryUIController.cs @@ -40,7 +40,7 @@ public sealed partial class RoundEndSummaryUIController : UIController, return; _window = new RoundEndSummaryWindow(message.GamemodeTitle, message.RoundEndText, - message.RoundDuration, message.RoundId, message.AllPlayersEndInfo, EntityManager); + message.RoundDuration, message.RoundId, message.AllPlayersEndInfo); } public void OnSystemLoaded(ClientGameTicker system) diff --git a/Content.Client/RoundEnd/RoundEndSummaryWindow.cs b/Content.Client/RoundEnd/RoundEndSummaryWindow.cs index f0a1678b75..6224c35f5a 100644 --- a/Content.Client/RoundEnd/RoundEndSummaryWindow.cs +++ b/Content.Client/RoundEnd/RoundEndSummaryWindow.cs @@ -1,171 +1,496 @@ using System.Linq; using System.Numerics; using Content.Client.Message; -using Content.Shared.GameTicking; +using RoundEndPlayerInfo = Content.Shared.GameTicking.RoundEndMessageEvent.RoundEndPlayerInfo; +using Robust.Client.UserInterface; using Robust.Client.UserInterface.Controls; using Robust.Client.UserInterface.CustomControls; using Robust.Shared.Utility; using static Robust.Client.UserInterface.Controls.BoxContainer; -namespace Content.Client.RoundEnd +namespace Content.Client.RoundEnd; + +/// +/// Window displaying round end information including player manifest. +/// +public sealed partial class RoundEndSummaryWindow : DefaultWindow { - public sealed class RoundEndSummaryWindow : DefaultWindow + [Dependency] private IEntityManager _entityManager = default!; + + public int RoundId; + private readonly RoundEndPlayerInfo[] _playersInfo; + private GridContainer _playerGrid = null!; + private readonly List _sortButtons = []; + private string _searchText = string.Empty; + + private enum SortField { - private readonly IEntityManager _entityManager; - public int RoundId; + ICName, + Role, + PlayerType, + OOCName + } - public RoundEndSummaryWindow(string gm, string roundEnd, TimeSpan roundTimeSpan, int roundId, - RoundEndMessageEvent.RoundEndPlayerInfo[] info, IEntityManager entityManager) + private SortField _currentSortField = SortField.PlayerType; + private bool _sortDescending; + + public RoundEndSummaryWindow(string gm, string roundEnd, TimeSpan roundTimeSpan, int roundId, RoundEndPlayerInfo[] info) + { + IoCManager.InjectDependencies(this); + _playersInfo = info; + + MinSize = SetSize = new Vector2(720, 580); + + Title = Loc.GetString("round-end-summary-window-title"); + + // The round end window is split into two tabs, one about the round stats + // and the other is a list of RoundEndPlayerInfo for each player. + // This tab would be a good place for things like: "x many people died.", + // "clown slipped the crew x times.", "x shots were fired this round.", etc. + // Also, good for serious info. + + RoundId = roundId; + var roundEndTabs = new TabContainer(); + roundEndTabs.AddChild(MakeRoundEndSummaryTab(gm, roundEnd, roundTimeSpan, roundId)); + roundEndTabs.AddChild(MakePlayerManifestTab()); + + ContentsContainer.AddChild(roundEndTabs); + + OpenCenteredRight(); + MoveToFront(); + } + + private static BoxContainer MakeRoundEndSummaryTab(string gamemode, string roundEnd, TimeSpan roundDuration, int roundId) + { + var roundEndSummaryTab = new BoxContainer { - _entityManager = entityManager; + Orientation = LayoutOrientation.Vertical, + Name = Loc.GetString("round-end-summary-window-round-end-summary-tab-title") + }; - MinSize = SetSize = new Vector2(520, 580); + var roundEndSummaryContainerScrollbox = new ScrollContainer + { + VerticalExpand = true, + Margin = new Thickness(10) + }; + var roundEndSummaryContainer = new BoxContainer + { + Orientation = LayoutOrientation.Vertical + }; - Title = Loc.GetString("round-end-summary-window-title"); + //Gamemode Name + var gamemodeLabel = new RichTextLabel(); + var gamemodeMessage = new FormattedMessage(); + gamemodeMessage.AddMarkupOrThrow(Loc.GetString("round-end-summary-window-round-id-label", ("roundId", roundId))); + gamemodeMessage.AddText(" "); + gamemodeMessage.AddMarkupOrThrow(Loc.GetString("round-end-summary-window-gamemode-name-label", ("gamemode", gamemode))); + gamemodeLabel.SetMessage(gamemodeMessage); + roundEndSummaryContainer.AddChild(gamemodeLabel); - // The round end window is split into two tabs, one about the round stats - // and the other is a list of RoundEndPlayerInfo for each player. - // This tab would be a good place for things like: "x many people died.", - // "clown slipped the crew x times.", "x shots were fired this round.", etc. - // Also good for serious info. + //Duration + var roundTimeLabel = new RichTextLabel(); + roundTimeLabel.SetMarkup(Loc.GetString("round-end-summary-window-duration-label", + ("hours", roundDuration.Hours), + ("minutes", roundDuration.Minutes), + ("seconds", roundDuration.Seconds))); + roundEndSummaryContainer.AddChild(roundTimeLabel); - RoundId = roundId; - var roundEndTabs = new TabContainer(); - roundEndTabs.AddChild(MakeRoundEndSummaryTab(gm, roundEnd, roundTimeSpan, roundId)); - roundEndTabs.AddChild(MakePlayerManifestTab(info)); - - ContentsContainer.AddChild(roundEndTabs); - - OpenCenteredRight(); - MoveToFront(); + //Round end text + if (!string.IsNullOrEmpty(roundEnd)) + { + var roundEndLabel = new RichTextLabel(); + roundEndLabel.SetMarkup(roundEnd); + roundEndSummaryContainer.AddChild(roundEndLabel); } - private BoxContainer MakeRoundEndSummaryTab(string gamemode, string roundEnd, TimeSpan roundDuration, int roundId) + roundEndSummaryContainerScrollbox.AddChild(roundEndSummaryContainer); + roundEndSummaryTab.AddChild(roundEndSummaryContainerScrollbox); + + return roundEndSummaryTab; + } + + private BoxContainer MakePlayerManifestTab() + { + var playerManifestTab = new BoxContainer { - var roundEndSummaryTab = new BoxContainer - { - Orientation = LayoutOrientation.Vertical, - Name = Loc.GetString("round-end-summary-window-round-end-summary-tab-title") - }; + Orientation = LayoutOrientation.Vertical, + Name = Loc.GetString("round-end-summary-window-player-manifest-tab-title") + }; - var roundEndSummaryContainerScrollbox = new ScrollContainer - { - VerticalExpand = true, - Margin = new Thickness(10) - }; - var roundEndSummaryContainer = new BoxContainer - { - Orientation = LayoutOrientation.Vertical - }; + // Search container + var searchContainer = new BoxContainer + { + Orientation = LayoutOrientation.Horizontal, + Margin = new Thickness(10, 10, 10, 5) + }; - //Gamemode Name - var gamemodeLabel = new RichTextLabel(); - var gamemodeMessage = new FormattedMessage(); - gamemodeMessage.AddMarkupOrThrow(Loc.GetString("round-end-summary-window-round-id-label", ("roundId", roundId))); - gamemodeMessage.AddText(" "); - gamemodeMessage.AddMarkupOrThrow(Loc.GetString("round-end-summary-window-gamemode-name-label", ("gamemode", gamemode))); - gamemodeLabel.SetMessage(gamemodeMessage); - roundEndSummaryContainer.AddChild(gamemodeLabel); + var searchLabel = new Label + { + Text = "Filter: ", + VerticalAlignment = VAlignment.Center, + MinSize = new Vector2(40, 1) + }; - //Duration - var roundTimeLabel = new RichTextLabel(); - roundTimeLabel.SetMarkup(Loc.GetString("round-end-summary-window-duration-label", - ("hours", roundDuration.Hours), - ("minutes", roundDuration.Minutes), - ("seconds", roundDuration.Seconds))); - roundEndSummaryContainer.AddChild(roundTimeLabel); + var searchBar = new LineEdit + { + PlaceHolder = Loc.GetString("round-end-summary-window-player-manifest-tab-search-placeholder"), + HorizontalExpand = true, + MinSize = new Vector2(200, 1) + }; - //Round end text - if (!string.IsNullOrEmpty(roundEnd)) - { - var roundEndLabel = new RichTextLabel(); - roundEndLabel.SetMarkup(roundEnd); - roundEndSummaryContainer.AddChild(roundEndLabel); - } + searchBar.OnTextChanged += OnSearchTextChanged; - roundEndSummaryContainerScrollbox.AddChild(roundEndSummaryContainer); - roundEndSummaryTab.AddChild(roundEndSummaryContainerScrollbox); + searchContainer.AddChild(searchLabel); + searchContainer.AddChild(searchBar); + playerManifestTab.AddChild(searchContainer); - return roundEndSummaryTab; + // Header with sort buttons + var headerContainer = new BoxContainer + { + Orientation = LayoutOrientation.Horizontal, + Margin = new Thickness(10, 5, 10, 5) + }; + + var icNameButton = CreateSortButton("round-end-summary-window-player-manifest-tab-sort-character", SortField.ICName); + var roleButton = CreateSortButton("round-end-summary-window-player-manifest-tab-sort-role", SortField.Role); + var playerTypeButton = CreateSortButton("round-end-summary-window-player-manifest-tab-sort-player-type", SortField.PlayerType); + var oocNameButton = CreateSortButton("round-end-summary-window-player-manifest-tab-sort-player", SortField.OOCName); + + playerTypeButton.SetSortIndicator(true); + headerContainer.AddChild(icNameButton); + + // Add small spacer between buttons + headerContainer.AddChild(new Control + { + MinSize = new Vector2(5, 1), + HorizontalExpand = false + }); + + headerContainer.AddChild(roleButton); + + // Add small spacer between buttons + headerContainer.AddChild(new Control + { + MinSize = new Vector2(5, 1), + HorizontalExpand = false + }); + + headerContainer.AddChild(playerTypeButton); + + // Add small spacer between buttons + headerContainer.AddChild(new Control + { + MinSize = new Vector2(5, 1), + HorizontalExpand = false + }); + + headerContainer.AddChild(oocNameButton); + + playerManifestTab.AddChild(headerContainer); + + var scrollContainer = new ScrollContainer + { + VerticalExpand = true, + Margin = new Thickness(10, 0, 10, 10), + }; + + _playerGrid = new GridContainer + { + Columns = 6, // Player Sprite,IC Name,Role,Player Type,OOC Name + HorizontalExpand = true, + }; + + RefreshPlayerList(); + + scrollContainer.AddChild(_playerGrid); + playerManifestTab.AddChild(scrollContainer); + + return playerManifestTab; + } + + private SortButton CreateSortButton(string text, SortField field) + { + var button = new SortButton(Loc.GetString(text), field); + button.OnPressed += _ => SortBy(field); + _sortButtons.Add(button); + return button; + } + + /// + /// Handles sorting by the specified field, toggling direction if the same field is clicked + /// + private void SortBy(SortField field) + { + if (_currentSortField == field) + { + _sortDescending = !_sortDescending; + } + else + { + _currentSortField = field; + _sortDescending = false; } - private BoxContainer MakePlayerManifestTab(RoundEndMessageEvent.RoundEndPlayerInfo[] playersInfo) + foreach (var button in _sortButtons) { - var playerManifestTab = new BoxContainer - { - Orientation = LayoutOrientation.Vertical, - Name = Loc.GetString("round-end-summary-window-player-manifest-tab-title") - }; + button.SetSortIndicator(button.Field == _currentSortField, _sortDescending); + } - var playerInfoContainerScrollbox = new ScrollContainer - { - VerticalExpand = true, - Margin = new Thickness(10) - }; - var playerInfoContainer = new BoxContainer - { - Orientation = LayoutOrientation.Vertical - }; + RefreshPlayerList(); + } - //Put observers at the bottom of the list. Put antags on top. - var sortedPlayersInfo = playersInfo.OrderBy(p => p.Observer).ThenBy(p => !p.Antag); + /// + /// Refreshes the player list grid by clearing it and repopulating with sorted player data + /// + private void RefreshPlayerList() + { + _playerGrid.RemoveAllChildren(); - //Create labels for each player info. - foreach (var playerInfo in sortedPlayersInfo) - { - var hBox = new BoxContainer - { - Orientation = LayoutOrientation.Horizontal, - }; - - var playerInfoText = new RichTextLabel - { - VerticalAlignment = VAlignment.Center, - VerticalExpand = true, - }; - - if (playerInfo.PlayerNetEntity != null) - { - hBox.AddChild(new SpriteView(playerInfo.PlayerNetEntity.Value, _entityManager) - { - OverrideDirection = Direction.South, - VerticalAlignment = VAlignment.Center, - SetSize = new Vector2(32, 32), - VerticalExpand = true, - }); - } - - if (playerInfo.PlayerICName != null) - { - if (playerInfo.Observer) - { - playerInfoText.SetMarkup( - Loc.GetString("round-end-summary-window-player-info-if-observer-text", - ("playerOOCName", playerInfo.PlayerOOCName), - ("playerICName", playerInfo.PlayerICName))); - } - else - { - //TODO: On Hover display a popup detailing more play info. - //For example: their antag goals and if they completed them sucessfully. - var icNameColor = playerInfo.Antag ? "red" : "white"; - playerInfoText.SetMarkup( - Loc.GetString("round-end-summary-window-player-info-if-not-observer-text", - ("playerOOCName", playerInfo.PlayerOOCName), - ("icNameColor", icNameColor), - ("playerICName", playerInfo.PlayerICName), - ("playerRole", Loc.GetString(playerInfo.Role)))); - } - } - hBox.AddChild(playerInfoText); - playerInfoContainer.AddChild(hBox); - } - - playerInfoContainerScrollbox.AddChild(playerInfoContainer); - playerManifestTab.AddChild(playerInfoContainerScrollbox); - - return playerManifestTab; + var sortedPlayers = GetSortedPlayers(); + foreach (var playerInfo in sortedPlayers) + { + AddPlayerRow(playerInfo); } } + /// + /// Adds a single player row to the grid with all columns (sprite, IC name, role, player type, OOC name) + /// + private void AddPlayerRow(RoundEndPlayerInfo playerInfo) + { + // Player Sprite column + if (playerInfo.PlayerNetEntity != null) + { + _playerGrid.AddChild(new SpriteView(playerInfo.PlayerNetEntity.Value, _entityManager) + { + OverrideDirection = Direction.South, + VerticalAlignment = VAlignment.Center, + SetSize = new Vector2(32, 32), + }); + } + else + { + _playerGrid.AddChild(new Control + { + SetSize = new Vector2(32, 32), + }); + } + + // IC Name column + var icNameLabel = new Label + { + Text = playerInfo.PlayerICName ?? playerInfo.PlayerOOCName, + VerticalAlignment = VAlignment.Center, + HorizontalExpand = true, + ClipText = true + }; + + // Apply color coding for antagonists + if (playerInfo.Antag) + { + icNameLabel.FontColorOverride = Color.Red; + } + + _playerGrid.AddChild(icNameLabel); + + _playerGrid.AddChild(new Control + { + SetSize = new Vector2(32, 32), + }); + + // Role column + var roleLabel = new Label + { + Text = playerInfo.Observer ? "-" : Loc.GetString(playerInfo.Role), + VerticalAlignment = VAlignment.Center, + HorizontalExpand = true, + ClipText = true + }; + _playerGrid.AddChild(roleLabel); + + // Player Type column + var playerTypeLabel = new Label + { + Text = GetPlayerTypeText(playerInfo), + VerticalAlignment = VAlignment.Center, + HorizontalExpand = true, + ClipText = true + }; + + // Apply color coding based on player type + if (playerInfo.Antag) + { + playerTypeLabel.FontColorOverride = Color.Red; + } + else if (playerInfo.Observer) + { + playerTypeLabel.FontColorOverride = Color.Gray; + } + + _playerGrid.AddChild(playerTypeLabel); + + // OOC Name column + var oocNameLabel = new Label + { + Text = playerInfo.PlayerOOCName, + VerticalAlignment = VAlignment.Center, + HorizontalExpand = true, + ClipText = true + }; + + _playerGrid.AddChild(oocNameLabel); + } + + /// + /// Gets the player type text for a player based on their observer and antagonist flags + /// + private static string GetPlayerTypeText(RoundEndPlayerInfo playerInfo) + { + if (playerInfo.Observer) + return Loc.GetString("round-end-summary-window-player-manifest-tab-sort-player-type-observer"); + if (playerInfo.Antag) + return Loc.GetString("round-end-summary-window-player-manifest-tab-sort-player-type-antag"); + + return Loc.GetString("round-end-summary-window-player-manifest-tab-sort-player-type-crew"); + } + + private IEnumerable GetSortedPlayers() + { + // First filter players based on search text + var filteredPlayers = string.IsNullOrEmpty(_searchText) + ? _playersInfo + : _playersInfo.Where(PlayerMatchesSearch); + + static string GetIcKey(RoundEndPlayerInfo p) => + (p.PlayerICName ?? p.PlayerOOCName).ToLowerInvariant(); + + static string GetOocKey(RoundEndPlayerInfo p) => + p.PlayerOOCName.ToLowerInvariant(); + + static string GetRoleKey(RoundEndPlayerInfo p) => + (p.Observer ? "zzz_observer" : p.Role).ToLowerInvariant(); + + static int GetPlayerTypeSortKey(RoundEndPlayerInfo p) => + p.Antag ? 1 : p.Observer ? 3 : 2; + + return _currentSortField switch + { + SortField.ICName => ApplySort(filteredPlayers, GetIcKey, _sortDescending), + SortField.OOCName => ApplySort(filteredPlayers, GetOocKey, _sortDescending), + SortField.Role => ApplySort(filteredPlayers, GetRoleKey, _sortDescending), + SortField.PlayerType => ApplySort(filteredPlayers, GetPlayerTypeSortKey, _sortDescending), + _ => filteredPlayers + }; + } + + private static IEnumerable ApplySort( + IEnumerable players, + Func primaryKey, + bool descending) + { + static string SecondaryKey(RoundEndPlayerInfo p) => + (p.PlayerICName ?? p.PlayerOOCName).ToLowerInvariant(); + + return descending + ? players.OrderByDescending(primaryKey).ThenByDescending(SecondaryKey) + : players.OrderBy(primaryKey).ThenBy(SecondaryKey); + } + + /// + /// Gets a sort key for player type to ensure consistent ordering: Antagonist -> Crew -> Observer + /// + + /// + /// Checks if a player matches the current search filter + /// + private bool PlayerMatchesSearch(RoundEndPlayerInfo playerInfo) + { + if (string.IsNullOrEmpty(_searchText)) + return true; + + // Search in character name (IC name) + if (!string.IsNullOrEmpty(playerInfo.PlayerICName) && + playerInfo.PlayerICName.Contains(_searchText, StringComparison.OrdinalIgnoreCase)) + return true; + + // Search in player name (OOC name) + if (!string.IsNullOrEmpty(playerInfo.PlayerOOCName) && + playerInfo.PlayerOOCName.Contains(_searchText, StringComparison.OrdinalIgnoreCase)) + return true; + + // Search in role + if (!string.IsNullOrEmpty(playerInfo.Role)) + { + if (playerInfo.Role.Contains(_searchText, StringComparison.OrdinalIgnoreCase) || + Loc.GetString(playerInfo.Role).Contains(_searchText, StringComparison.OrdinalIgnoreCase)) + return true; + } + + // Search in player type + var playerType = GetPlayerTypeText(playerInfo); + if (playerType.Contains(_searchText, StringComparison.OrdinalIgnoreCase)) + return true; + + // Search for "Observer" when they are observers + if (playerInfo.Observer && "observer".Contains(_searchText, StringComparison.OrdinalIgnoreCase)) + return true; + + return false; + } + + /// + /// Handles search text changes and refreshes the player list + /// + private void OnSearchTextChanged(LineEdit.LineEditEventArgs args) + { + _searchText = args.Text; + RefreshPlayerList(); + } + + private sealed class SortButton : Button + { + public SortField Field { get; } + private readonly Label _sortIndicator; + + public SortButton(string text, SortField field) + { + Field = field; + HorizontalExpand = true; + + var container = new BoxContainer + { + Orientation = LayoutOrientation.Horizontal, + HorizontalExpand = true + }; + + var label = new Label + { + Text = text, + HorizontalExpand = true + }; + + _sortIndicator = new Label + { + Text = "", + HorizontalAlignment = HAlignment.Right, + MinSize = new Vector2(15, 1) + }; + + container.AddChild(label); + container.AddChild(_sortIndicator); + + AddChild(container); + } + + public void SetSortIndicator(bool active, bool descending = false) + { + if (!active) + { + _sortIndicator.Text = ""; + return; + } + + _sortIndicator.Text = descending ? "▼" : "▲"; + } + } } diff --git a/Resources/Locale/en-US/round-end/round-end-summary-window.ftl b/Resources/Locale/en-US/round-end/round-end-summary-window.ftl index 58d26319b3..1240632f1c 100644 --- a/Resources/Locale/en-US/round-end/round-end-summary-window.ftl +++ b/Resources/Locale/en-US/round-end/round-end-summary-window.ftl @@ -6,3 +6,12 @@ round-end-summary-window-gamemode-name-label = The game mode was [color=white]{$ round-end-summary-window-duration-label = It lasted for [color=yellow]{$hours} hours, {$minutes} minutes, and {$seconds} seconds. round-end-summary-window-player-info-if-observer-text = [color=gray]{$playerOOCName}[/color] was [color=lightblue]{$playerICName}[/color], an observer. round-end-summary-window-player-info-if-not-observer-text = [color=gray]{$playerOOCName}[/color] was [color={$icNameColor}]{$playerICName}[/color] playing role of [color=orange]{$playerRole}[/color]. +round-end-summary-window-player-manifest-tab-search-placeholder = Search players, roles, types... +round-end-summary-window-player-manifest-tab-sort-character = Character +round-end-summary-window-player-manifest-tab-sort-role = Role +round-end-summary-window-player-manifest-tab-sort-player-type = Type +round-end-summary-window-player-manifest-tab-sort-player = Player +round-end-summary-window-player-manifest-tab-sort-player-type-antag = Antagonist +round-end-summary-window-player-manifest-tab-sort-player-type-crew = Crew +round-end-summary-window-player-manifest-tab-sort-player-type-observer = Observer +