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 <ajcm_mail@disroot.org>
This commit is contained in:
Crotalus 2026-07-11 22:34:53 +02:00 • committed by GitHub
parent 4f92968508
commit b5d8def08a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 471 additions and 137 deletions

View file

@ -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)

View file

@ -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;
/// <summary>
/// Window displaying round end information including player manifest.
/// </summary>
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<SortButton> _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;
}
/// <summary>
/// Handles sorting by the specified field, toggling direction if the same field is clicked
/// </summary>
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);
/// <summary>
/// Refreshes the player list grid by clearing it and repopulating with sorted player data
/// </summary>
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);
}
}
/// <summary>
/// Adds a single player row to the grid with all columns (sprite, IC name, role, player type, OOC name)
/// </summary>
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);
}
/// <summary>
/// Gets the player type text for a player based on their observer and antagonist flags
/// </summary>
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<RoundEndPlayerInfo> 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<RoundEndPlayerInfo> ApplySort<TKey>(
IEnumerable<RoundEndPlayerInfo> players,
Func<RoundEndPlayerInfo, TKey> 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);
}
/// <summary>
/// Gets a sort key for player type to ensure consistent ordering: Antagonist -> Crew -> Observer
/// </summary>
/// <summary>
/// Checks if a player matches the current search filter
/// </summary>
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;
}
/// <summary>
/// Handles search text changes and refreshes the player list
/// </summary>
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 ? "▼" : "▲";
}
}
}

View file

@ -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