diff --git a/managed/src/SwiftlyS2.Core/Modules/Menus/MenuAPI.cs b/managed/src/SwiftlyS2.Core/Modules/Menus/MenuAPI.cs
index 4add60b44..dee4cfc95 100644
--- a/managed/src/SwiftlyS2.Core/Modules/Menus/MenuAPI.cs
+++ b/managed/src/SwiftlyS2.Core/Modules/Menus/MenuAPI.cs
@@ -2,6 +2,7 @@
using SwiftlyS2.Shared;
using SwiftlyS2.Shared.Menus;
using SwiftlyS2.Core.Natives;
+using SwiftlyS2.Core.Menus.OptionsBase;
using SwiftlyS2.Shared.Players;
using SwiftlyS2.Shared.SchemaDefinitions;
@@ -41,6 +42,11 @@ internal sealed class MenuAPI : IMenuAPI, IDisposable
///
public IMenuBuilderAPI? Builder { get; init; }
+ ///
+ /// Gets or sets the default comment text to use when a menu option's Comment is not set.
+ ///
+ public string DefaultComment { get; set; } = $"Powered by ❤️ {HtmlGradient.GenerateGradientText("SwiftlyS2", "#ffffff", "#96d5ff")}";
+
///
/// Gets or sets an object that contains data about this menu.
///
@@ -252,9 +258,10 @@ private void OnRender()
foreach (var option in options)
{
- if (option is OptionsBase.MenuOptionBase optionBase)
+ if (option is MenuOptionBase optionBase)
{
optionBase.UpdateDynamicText(now);
+ optionBase.UpdateCustomAnimations(now);
}
}
@@ -418,22 +425,47 @@ private string BuildMenuHtml( IPlayer player, IReadOnlyList visible
option.GetDisplayText(player, 0)
)));
+ var currentOption = visibleOptions.Count > 0 ? visibleOptions[arrowPosition] : null;
+ var optionBase = currentOption as MenuOptionBase;
+
+ var comment = !string.IsNullOrWhiteSpace(optionBase?.Comment)
+ ? string.Concat(
+ "
",
+ guideLine,
+ "
",
+ $"{optionBase.Comment}
"
+ )
+ : string.Concat(
+ "
",
+ guideLine,
+ "
",
+ $"{DefaultComment}
"
+ );
+
+ var claimInfo = optionBase?.InputClaimInfo ?? MenuInputClaimInfo.Empty;
+
var footerSection = Configuration.HideFooter ? string.Empty :
core.MenusAPI.Configuration.InputMode switch {
"wasd" => string.Concat(
- "
", guideLine, "
",
"",
$"Move: W/S",
- $" | Use: D",
- Configuration.DisableExit ? string.Empty : $" | Exit: A",
+ claimInfo.ClaimsUse
+ ? $" | {claimInfo.UseLabel ?? "Use"}: D"
+ : $" | Use: D",
+ claimInfo.ClaimsExit
+ ? $" | {claimInfo.ExitLabel ?? "Exit"}: A"
+ : (Configuration.DisableExit ? string.Empty : $" | Exit: A"),
""
),
_ => string.Concat(
- "
", guideLine, "
",
"",
$"Move: {KeybindOverrides.Move?.ToString() ?? core.MenusAPI.Configuration.ButtonsScroll.ToUpper()}/{KeybindOverrides.MoveBack?.ToString() ?? core.MenusAPI.Configuration.ButtonsScrollBack.ToUpper()}",
- $" | Use: {KeybindOverrides.Select?.ToString() ?? core.MenusAPI.Configuration.ButtonsUse.ToUpper()}",
- Configuration.DisableExit ? string.Empty : $" | Exit: {KeybindOverrides.Exit?.ToString() ?? core.MenusAPI.Configuration.ButtonsExit.ToUpper()}",
+ claimInfo.ClaimsUse
+ ? $" | {claimInfo.UseLabel ?? "Use"}: {KeybindOverrides.Select?.ToString() ?? core.MenusAPI.Configuration.ButtonsUse.ToUpper()}"
+ : $" | Use: {KeybindOverrides.Select?.ToString() ?? core.MenusAPI.Configuration.ButtonsUse.ToUpper()}",
+ claimInfo.ClaimsExit
+ ? $" | {claimInfo.ExitLabel ?? "Exit"}: {KeybindOverrides.Exit?.ToString() ?? core.MenusAPI.Configuration.ButtonsExit.ToUpper()}"
+ : (Configuration.DisableExit ? string.Empty : $" | Exit: {KeybindOverrides.Exit?.ToString() ?? core.MenusAPI.Configuration.ButtonsExit.ToUpper()}"),
""
)
};
@@ -443,6 +475,7 @@ private string BuildMenuHtml( IPlayer player, IReadOnlyList visible
"",
menuItems,
"",
+ comment,
footerSection
);
}
@@ -499,7 +532,7 @@ public void ShowForPlayer( IPlayer player )
lock (optionsLock)
{
- options.OfType().ToList().ForEach(option => option.ResumeTextAnimation());
+ options.OfType().ToList().ForEach(option => option.ResumeTextAnimation());
}
}
}
@@ -556,7 +589,7 @@ public void HideForPlayer( IPlayer player )
lock (optionsLock)
{
- options.OfType().ToList().ForEach(option => option.PauseTextAnimation());
+ options.OfType().ToList().ForEach(option => option.PauseTextAnimation());
}
}
}
@@ -588,7 +621,7 @@ public void AddOption( IMenuOption option )
// {
// submenuOption.SubmenuRequested += OnSubmenuRequested;
// }
- if (option is OptionsBase.MenuOptionBase baseOption)
+ if (option is MenuOptionBase baseOption)
{
baseOption.Menu = this;
}
@@ -667,17 +700,20 @@ public int GetCurrentOptionIndex( IPlayer player )
// return selectedDisplayLine.TryGetValue(player, out var line) ? line : -1;
// }
- private static void SetFreezeState( IPlayer player, bool freeze )
+ private void SetFreezeState( IPlayer player, bool freeze )
{
if (!player.IsValid || player.IsFakeClient || !(player.PlayerPawn?.IsValid ?? false))
{
return;
}
- var moveType = freeze ? MoveType_t.MOVETYPE_NONE : MoveType_t.MOVETYPE_WALK;
- player.PlayerPawn.MoveType = moveType;
- player.PlayerPawn.ActualMoveType = moveType;
- player.PlayerPawn.MoveTypeUpdated();
+ core.Scheduler.NextTick(() =>
+ {
+ var moveType = freeze ? MoveType_t.MOVETYPE_NONE : MoveType_t.MOVETYPE_WALK;
+ player.PlayerPawn.MoveType = moveType;
+ player.PlayerPawn.ActualMoveType = moveType;
+ player.PlayerPawn.MoveTypeUpdated();
+ });
}
// private ValueTask OnOptionClick( object? sender, MenuOptionClickEventArgs args )
diff --git a/managed/src/SwiftlyS2.Core/Modules/Menus/MenuInputClaim.cs b/managed/src/SwiftlyS2.Core/Modules/Menus/MenuInputClaim.cs
new file mode 100644
index 000000000..2a7fd9769
--- /dev/null
+++ b/managed/src/SwiftlyS2.Core/Modules/Menus/MenuInputClaim.cs
@@ -0,0 +1,60 @@
+namespace SwiftlyS2.Core.Menus;
+
+[Flags]
+internal enum MenuInputClaim
+{
+ ///
+ /// No input claimed.
+ ///
+ None = 0,
+
+ ///
+ /// Claims the Exit key (A in WASD mode).
+ ///
+ Exit = 1 << 0,
+
+ ///
+ /// Claims the Use/Select key (D in WASD mode).
+ ///
+ Use = 1 << 1
+}
+
+internal readonly record struct MenuInputClaimInfo
+{
+ ///
+ /// The input types being claimed.
+ ///
+ public MenuInputClaim Claims { get; init; }
+
+ ///
+ /// The display label for the Exit key when claimed.
+ /// Shown in the menu footer instead of "Exit".
+ ///
+ public string? ExitLabel { get; init; }
+
+ ///
+ /// The display label for the Use key when claimed.
+ /// Shown in the menu footer instead of "Use".
+ ///
+ public string? UseLabel { get; init; }
+
+ ///
+ /// Returns true if any input is claimed.
+ ///
+ public bool HasClaims => Claims != MenuInputClaim.None;
+
+ ///
+ /// Returns true if the Exit input is claimed.
+ ///
+ public bool ClaimsExit => (Claims & MenuInputClaim.Exit) != 0;
+
+ ///
+ /// Returns true if the Use input is claimed.
+ ///
+ public bool ClaimsUse => (Claims & MenuInputClaim.Use) != 0;
+
+ ///
+ /// Creates an empty claim info with no claims.
+ ///
+ public static MenuInputClaimInfo Empty => new() { Claims = MenuInputClaim.None };
+}
\ No newline at end of file
diff --git a/managed/src/SwiftlyS2.Core/Modules/Menus/MenuManagerAPI.cs b/managed/src/SwiftlyS2.Core/Modules/Menus/MenuManagerAPI.cs
index 3256e2ec4..0b00a3cb6 100644
--- a/managed/src/SwiftlyS2.Core/Modules/Menus/MenuManagerAPI.cs
+++ b/managed/src/SwiftlyS2.Core/Modules/Menus/MenuManagerAPI.cs
@@ -2,6 +2,7 @@
using System.Collections.Concurrent;
using SwiftlyS2.Shared;
using SwiftlyS2.Core.Natives;
+using SwiftlyS2.Core.Menus.OptionsBase;
using SwiftlyS2.Shared.Menus;
using SwiftlyS2.Shared.Events;
using SwiftlyS2.Shared.Sounds;
@@ -113,9 +114,13 @@ internal void OnClientKeyStateChanged( IOnClientKeyStateChangedEvent @event )
}
var player = Core.PlayerManager.GetPlayer(@event.PlayerId);
- var menu = GetCurrentMenu(player);
+ if (player == null || !player.IsValid || player.IsFakeClient || player.IsFakeClient || !@event.Pressed)
+ {
+ return;
+ }
- if (menu == null || !player.IsValid || player.IsFakeClient || !@event.Pressed)
+ var menu = GetCurrentMenu(player);
+ if (menu == null)
{
return;
}
@@ -151,7 +156,22 @@ internal void OnClientKeyStateChanged( IOnClientKeyStateChangedEvent @event )
}
else if (exitKey.HasFlag(@event.Key.ToKeyBind()))
{
- if (!menu.Configuration.DisableExit)
+ var option = menu.GetCurrentOption(player);
+ var optionBase = option as MenuOptionBase;
+ var claimInfo = optionBase?.InputClaimInfo ?? MenuInputClaimInfo.Empty;
+
+ if (claimInfo.ClaimsExit && optionBase != null)
+ {
+ optionBase.OnClaimedExit(player);
+
+ if (menu.Configuration.PlaySound && option!.PlaySound)
+ {
+ useSound.Recipients.AddRecipient(@event.PlayerId);
+ _ = useSound.Emit();
+ useSound.Recipients.RemoveRecipient(@event.PlayerId);
+ }
+ }
+ else if (!menu.Configuration.DisableExit)
{
CloseMenuForPlayerInternal(player, menu, true);
@@ -166,7 +186,21 @@ internal void OnClientKeyStateChanged( IOnClientKeyStateChangedEvent @event )
else if (useKey.HasFlag(@event.Key.ToKeyBind()))
{
var option = menu.GetCurrentOption(player);
- if (option != null && option.Enabled && option.GetEnabled(player) && option.IsClickTaskCompleted(player))
+ var optionBase = option as MenuOptionBase;
+ var claimInfo = optionBase?.InputClaimInfo ?? MenuInputClaimInfo.Empty;
+
+ if (claimInfo.ClaimsUse && optionBase != null)
+ {
+ optionBase.OnClaimedUse(player);
+
+ if (menu.Configuration.PlaySound && option!.PlaySound)
+ {
+ useSound.Recipients.AddRecipient(@event.PlayerId);
+ _ = useSound.Emit();
+ useSound.Recipients.RemoveRecipient(@event.PlayerId);
+ }
+ }
+ else if (option != null && option.Enabled && option.GetEnabled(player) && option.IsClickTaskCompleted(player))
{
_ = Task.Run(async () => await option.OnClickAsync(player));
@@ -205,7 +239,22 @@ internal void OnClientKeyStateChanged( IOnClientKeyStateChangedEvent @event )
}
else if (KeyBind.A.HasFlag(@event.Key.ToKeyBind()))
{
- if (!menu.Configuration.DisableExit)
+ var option = menu.GetCurrentOption(player);
+ var optionBase = option as MenuOptionBase;
+ var claimInfo = optionBase?.InputClaimInfo ?? MenuInputClaimInfo.Empty;
+
+ if (claimInfo.ClaimsExit && optionBase != null)
+ {
+ optionBase.OnClaimedExit(player);
+
+ if (menu.Configuration.PlaySound && option!.PlaySound)
+ {
+ useSound.Recipients.AddRecipient(@event.PlayerId);
+ _ = useSound.Emit();
+ useSound.Recipients.RemoveRecipient(@event.PlayerId);
+ }
+ }
+ else if (!menu.Configuration.DisableExit)
{
CloseMenuForPlayerInternal(player, menu, true);
@@ -220,7 +269,21 @@ internal void OnClientKeyStateChanged( IOnClientKeyStateChangedEvent @event )
else if (KeyBind.D.HasFlag(@event.Key.ToKeyBind()))
{
var option = menu.GetCurrentOption(player);
- if (option != null && option.Enabled && option.GetEnabled(player) && option.IsClickTaskCompleted(player))
+ var optionBase = option as MenuOptionBase;
+ var claimInfo = optionBase?.InputClaimInfo ?? MenuInputClaimInfo.Empty;
+
+ if (claimInfo.ClaimsUse && optionBase != null)
+ {
+ optionBase.OnClaimedUse(player);
+
+ if (menu.Configuration.PlaySound && option!.PlaySound)
+ {
+ useSound.Recipients.AddRecipient(@event.PlayerId);
+ _ = useSound.Emit();
+ useSound.Recipients.RemoveRecipient(@event.PlayerId);
+ }
+ }
+ else if (option != null && option.Enabled && option.GetEnabled(player) && option.IsClickTaskCompleted(player))
{
_ = Task.Run(async () => await option.OnClickAsync(player));
@@ -376,8 +439,11 @@ public void CloseAllMenus()
while (currentMenu != null)
{
var player = Core.PlayerManager.GetPlayer(kvp.Key);
- currentMenu.HideForPlayer(player);
- MenuClosed?.Invoke(this, new MenuManagerEventArgs { Player = player, Menu = currentMenu });
+ if (player?.IsValid ?? false)
+ {
+ currentMenu.HideForPlayer(player);
+ MenuClosed?.Invoke(this, new MenuManagerEventArgs { Player = player, Menu = currentMenu });
+ }
currentMenu = currentMenu.Parent.ParentMenu;
}
_ = openMenus.TryRemove(kvp.Key, out _);
diff --git a/managed/src/SwiftlyS2.Core/Modules/Menus/OptionsBase/MenuOptionBase.cs b/managed/src/SwiftlyS2.Core/Modules/Menus/OptionsBase/MenuOptionBase.cs
index e184d5f08..45e839d8f 100644
--- a/managed/src/SwiftlyS2.Core/Modules/Menus/OptionsBase/MenuOptionBase.cs
+++ b/managed/src/SwiftlyS2.Core/Modules/Menus/OptionsBase/MenuOptionBase.cs
@@ -93,7 +93,7 @@ public virtual void Dispose()
///
/// Pauses the dynamic text animation.
///
- public void PauseTextAnimation()
+ public virtual void PauseTextAnimation()
{
dynamicTextUpdater?.Pause();
}
@@ -101,7 +101,7 @@ public void PauseTextAnimation()
///
/// Resumes the dynamic text animation.
///
- public void ResumeTextAnimation()
+ public virtual void ResumeTextAnimation()
{
dynamicTextUpdater?.Resume();
}
@@ -141,6 +141,14 @@ public string Text {
}
}
+ ///
+ /// Gets or sets the comment content displayed for this menu option.
+ ///
+ ///
+ /// This is a global property. Changing it will affect what all players see.
+ ///
+ public string Comment { get; set; }
+
///
/// The maximum display width for menu option text in relative units.
///
@@ -528,13 +536,42 @@ public virtual async ValueTask OnClickAsync( IPlayer player )
// Hover?.Invoke(this, new MenuOptionEventArgs { Player = player, Option = this });
// }
+ ///
+ /// Gets the input claim information for this option.
+ /// This is used internally to determine which keys this option claims for custom handling.
+ ///
+ internal MenuInputClaimInfo InputClaimInfo { get; set; } = MenuInputClaimInfo.Empty;
+
+ ///
+ /// Called when the claimed Exit key is pressed while this option is selected.
+ /// Override this method to handle custom Exit key behavior.
+ /// This method MUST be synchronous to ensure immediate UI feedback.
+ ///
+ /// The player who pressed the key.
+ internal virtual void OnClaimedExit( IPlayer player ) { }
+
+ ///
+ /// Called when the claimed Use key is pressed while this option is selected.
+ /// Override this method to handle custom Use key behavior.
+ /// This method MUST be synchronous to ensure immediate UI feedback.
+ ///
+ /// The player who pressed the key.
+ internal virtual void OnClaimedUse( IPlayer player ) { }
+
+ ///
+ /// Called to update custom animations for this option.
+ /// Override this method to implement custom animation logic.
+ ///
+ /// The current time.
+ internal virtual void UpdateCustomAnimations( DateTime now ) { }
+
///
/// Updates dynamic text.
///
///
/// Called by MenuAPI's render loop.
///
- internal void UpdateDynamicText( DateTime now )
+ internal virtual void UpdateDynamicText( DateTime now )
{
dynamicTextUpdater?.TryUpdate(now);
}
diff --git a/managed/src/SwiftlyS2.Core/Modules/Menus/OptionsBase/SelectorMenuOption.cs b/managed/src/SwiftlyS2.Core/Modules/Menus/OptionsBase/SelectorMenuOption.cs
new file mode 100644
index 000000000..89e6b144b
--- /dev/null
+++ b/managed/src/SwiftlyS2.Core/Modules/Menus/OptionsBase/SelectorMenuOption.cs
@@ -0,0 +1,322 @@
+using System.Collections.Concurrent;
+using SwiftlyS2.Shared;
+using SwiftlyS2.Shared.Menus;
+using SwiftlyS2.Shared.Players;
+using SwiftlyS2.Core.Menus.OptionsBase.Helpers;
+
+namespace SwiftlyS2.Core.Menus.OptionsBase;
+
+///
+/// Represents a selector menu option that allows cycling through a list of choices using left/right keys.
+/// Displays as: Label: PrevChoice [CurrentChoice] NextChoice
+/// This option claims the Exit and Use keys for previous and next selection respectively.
+///
+/// The type of the choices.
+public sealed class SelectorMenuOption : MenuOptionBase
+{
+ private readonly ConcurrentDictionary selectedIndices = new();
+ private readonly IReadOnlyList formattedChoices;
+ private readonly IReadOnlyList textProcessors; // Per-choice processor
+ private readonly int defaultIndex;
+ private readonly float itemMaxWidth;
+ private readonly int updateIntervalMs;
+ private readonly int pauseIntervalMs;
+
+ // Animation state per choice
+ private readonly string?[] dynamicTexts;
+ private readonly DateTime[] lastUpdateTimes;
+ private readonly DateTime[] pauseEndTimes;
+ private volatile bool isPaused;
+
+ ///
+ /// Gets the available choices for this selector.
+ ///
+ public IReadOnlyList Choices { get; init; }
+
+ ///
+ /// Gets or sets whether the selector should wrap around when reaching the end.
+ ///
+ public bool WrapAround { get; set; } = true;
+
+ ///
+ /// Occurs when the selected choice changes for a player.
+ ///
+ public event EventHandler>? SelectionChanged;
+
+ ///
+ /// Creates an instance of .
+ ///
+ /// The list of available choices.
+ /// The default selected index. Defaults to 0.
+ /// A function to format each choice for display. Defaults to ToString().
+ /// The maximum width for each choice item. Defaults to 10.
+ /// The interval in milliseconds between text animation updates. Defaults to 120ms.
+ /// The pause duration in milliseconds before starting the next animation cycle. Defaults to 1000ms.
+ ///
+ /// When using this constructor, the property must be manually set to specify the label.
+ ///
+ public SelectorMenuOption(
+ IEnumerable choices,
+ int defaultIndex = 0,
+ Func? displayFormatter = null,
+ float itemMaxWidth = 10f,
+ int updateIntervalMs = 120,
+ int pauseIntervalMs = 1000 ) : base()
+ {
+ var formatter = displayFormatter ?? (item => item?.ToString() ?? string.Empty);
+ var choicesList = choices.ToList();
+ this.Choices = choicesList.AsReadOnly();
+ this.formattedChoices = choicesList.Select(c => formatter(c)).ToList().AsReadOnly();
+
+ if (this.Choices.Count == 0)
+ {
+ Spectre.Console.AnsiConsole.WriteException(new ArgumentException("Choices cannot be empty.", nameof(choices)));
+ }
+
+ this.defaultIndex = Math.Clamp(defaultIndex, 0, Math.Max(0, this.Choices.Count - 1));
+ this.itemMaxWidth = Math.Max(1f, itemMaxWidth);
+ this.updateIntervalMs = Math.Max(15, updateIntervalMs);
+ this.pauseIntervalMs = Math.Max(15, pauseIntervalMs);
+
+ var processors = new TextStyleProcessor?[this.formattedChoices.Count];
+ for (var i = 0; i < this.formattedChoices.Count; i++)
+ {
+ if (Helper.EstimateTextWidth(this.formattedChoices[i]) > itemMaxWidth)
+ {
+ processors[i] = new TextStyleProcessor();
+ }
+ }
+ this.textProcessors = processors.ToList().AsReadOnly();
+ this.dynamicTexts = new string?[this.formattedChoices.Count];
+ this.lastUpdateTimes = new DateTime[this.formattedChoices.Count];
+ this.pauseEndTimes = new DateTime[this.formattedChoices.Count];
+
+ PlaySound = true;
+ isPaused = false;
+ selectedIndices.Clear();
+
+ InputClaimInfo = new MenuInputClaimInfo {
+ Claims = MenuInputClaim.Exit | MenuInputClaim.Use,
+ ExitLabel = "L",
+ UseLabel = "R"
+ };
+ }
+
+ ///
+ /// Creates an instance of .
+ ///
+ /// The label text to display.
+ /// The list of available choices.
+ /// The default selected index. Defaults to 0.
+ /// A function to format each choice for display. Defaults to ToString().
+ /// The maximum width for each choice item. Defaults to 10.
+ /// The interval in milliseconds between text animation updates. Defaults to 120ms.
+ /// The pause duration in milliseconds before starting the next animation cycle. Defaults to 1000ms.
+ public SelectorMenuOption(
+ string text,
+ IEnumerable choices,
+ int defaultIndex = 0,
+ Func? displayFormatter = null,
+ float itemMaxWidth = 10f,
+ int updateIntervalMs = 120,
+ int pauseIntervalMs = 1000 ) : this(choices, defaultIndex, displayFormatter, itemMaxWidth, updateIntervalMs, pauseIntervalMs)
+ {
+ Text = text;
+ }
+
+ public override void Dispose()
+ {
+ foreach (var processor in textProcessors)
+ {
+ processor?.Dispose();
+ }
+ selectedIndices.Clear();
+ base.Dispose();
+ }
+
+ public override void PauseTextAnimation()
+ {
+ base.PauseTextAnimation();
+ isPaused = true;
+ }
+
+ public override void ResumeTextAnimation()
+ {
+ base.ResumeTextAnimation();
+ isPaused = false;
+ }
+
+ public override string GetDisplayText( IPlayer player, int displayLine = 0 )
+ {
+ if (Choices.Count == 0)
+ {
+ return $"[Empty]";
+ }
+
+ var currentIndex = selectedIndices.GetOrAdd(player.PlayerID, defaultIndex);
+
+ // Calculate prev/next indices with wrap-around
+ var prevIndex = (((currentIndex - 1) % Choices.Count) + Choices.Count) % Choices.Count;
+ var nextIndex = (currentIndex + 1) % Choices.Count;
+
+ // Get displayed texts
+ var prevText = GetStyledText(prevIndex);
+ var currentText = GetStyledText(currentIndex);
+ var nextText = GetStyledText(nextIndex);
+
+ // Format: Label: prev [current] next
+ var disabledColor = Menu?.Configuration.DisabledColor ?? "#666666";
+ var selector = Choices.Count == 1
+ ? $"[{currentText}]"
+ : $"{prevText} [{currentText}] {nextText}";
+
+ return selector;
+ }
+
+ ///
+ /// Gets the currently selected index for the specified player.
+ ///
+ /// The player whose selection to retrieve.
+ /// The selected index.
+ public int GetSelectedIndex( IPlayer player )
+ {
+ return selectedIndices.GetOrAdd(player.PlayerID, defaultIndex);
+ }
+
+ ///
+ /// Gets the currently selected choice for the specified player.
+ ///
+ /// The player whose selection to retrieve.
+ /// The selected choice, or default if no choices available.
+ public T? GetSelectedChoice( IPlayer player )
+ {
+ if (Choices.Count == 0)
+ {
+ return default;
+ }
+
+ var index = selectedIndices.GetOrAdd(player.PlayerID, defaultIndex);
+ return Choices[index];
+ }
+
+ ///
+ /// Sets the selected index for the specified player.
+ ///
+ /// The player whose selection to set.
+ /// The index to select. Will be clamped to valid range.
+ public void SetSelectedIndex( IPlayer player, int index )
+ {
+ if (Choices.Count == 0)
+ {
+ return;
+ }
+
+ var clampedIndex = Math.Clamp(index, 0, Choices.Count - 1);
+ _ = selectedIndices.AddOrUpdate(player.PlayerID, clampedIndex, ( _, _ ) => clampedIndex);
+ }
+
+ internal override void UpdateCustomAnimations( DateTime now )
+ {
+ if (isPaused)
+ {
+ return;
+ }
+
+ for (var i = 0; i < formattedChoices.Count; i++)
+ {
+ var processor = textProcessors[i];
+ if (processor == null)
+ {
+ continue;
+ }
+
+ if (now < pauseEndTimes[i])
+ {
+ continue;
+ }
+
+ if (lastUpdateTimes[i] != DateTime.MinValue && (now - lastUpdateTimes[i]).TotalMilliseconds < updateIntervalMs)
+ {
+ continue;
+ }
+
+ var (styledText, offset) = processor.ApplyHorizontalStyle(
+ formattedChoices[i],
+ TextStyle,
+ itemMaxWidth
+ );
+
+ dynamicTexts[i] = styledText;
+ lastUpdateTimes[i] = now;
+
+ if (offset == 0)
+ {
+ pauseEndTimes[i] = now.AddMilliseconds(pauseIntervalMs);
+ }
+ }
+ }
+
+ internal override void OnClaimedExit( IPlayer player )
+ {
+ Navigate(player, -1);
+ }
+
+ internal override void OnClaimedUse( IPlayer player )
+ {
+ Navigate(player, 1);
+ }
+
+ private string GetStyledText( int index )
+ {
+ if (index < 0 || index >= formattedChoices.Count)
+ {
+ return string.Empty;
+ }
+
+ var dynamic = dynamicTexts[index];
+ if (!string.IsNullOrEmpty(dynamic))
+ {
+ return dynamic;
+ }
+
+ var processor = textProcessors[index];
+ if (processor == null)
+ {
+ return formattedChoices[index];
+ }
+
+ var (truncated, _) = processor.ApplyHorizontalStyle(
+ formattedChoices[index],
+ MenuOptionTextStyle.TruncateEnd,
+ itemMaxWidth
+ );
+ return truncated;
+ }
+
+ private void Navigate( IPlayer player, int direction )
+ {
+ if (Choices.Count == 0)
+ {
+ return;
+ }
+
+ var oldIndex = selectedIndices.GetOrAdd(player.PlayerID, defaultIndex);
+ var newIndex = WrapAround
+ ? (((oldIndex + direction) % Choices.Count) + Choices.Count) % Choices.Count
+ : Math.Clamp(oldIndex + direction, 0, Choices.Count - 1);
+
+ if (newIndex == oldIndex)
+ {
+ return;
+ }
+
+ _ = selectedIndices.AddOrUpdate(player.PlayerID, newIndex, ( _, _ ) => newIndex);
+
+ SelectionChanged?.Invoke(this, new MenuOptionValueChangedEventArgs {
+ Player = player,
+ Option = this,
+ OldValue = Choices[oldIndex],
+ NewValue = Choices[newIndex]
+ });
+ }
+}
\ No newline at end of file
diff --git a/managed/src/SwiftlyS2.Shared/HtmlGradient.cs b/managed/src/SwiftlyS2.Shared/HtmlGradient.cs
index fed2f4aa3..dbc55e7d2 100644
--- a/managed/src/SwiftlyS2.Shared/HtmlGradient.cs
+++ b/managed/src/SwiftlyS2.Shared/HtmlGradient.cs
@@ -8,7 +8,7 @@ public static class HtmlGradient
///
/// Generates gradient colored text by interpolating between two colors.
///
- /// The text to apply gradient to.
+ /// The plain text to apply gradient to. Must not contain any HTML tags.
/// The starting color in hex format (e.g., "#FF0000").
/// The ending color in hex format (e.g., "#0000FF").
/// HTML string with each character wrapped in a colored font tag.
@@ -36,7 +36,7 @@ public static string GenerateGradientText( string text, string startColor, strin
///
/// Generates gradient colored text by interpolating across multiple color stops.
///
- /// The text to apply gradient to.
+ /// The plain text to apply gradient to. Must not contain any HTML tags.
/// Array of color stops in hex format (e.g., "#FF0000", "#00FF00", "#0000FF").
/// HTML string with each character wrapped in a colored font tag.
public static string GenerateGradientText( string text, params string[] colors ) => (text, colors) switch {
@@ -49,7 +49,7 @@ public static string GenerateGradientText( string text, string startColor, strin
///
/// Generates gradient colored text by interpolating between two SwiftlyS2 native colors.
///
- /// The text to apply gradient to.
+ /// The plain text to apply gradient to. Must not contain any HTML tags.
/// The starting SwiftlyS2 native color.
/// The ending SwiftlyS2 native color.
/// HTML string with each character wrapped in a colored font tag.
@@ -61,7 +61,7 @@ public static string GenerateGradientText( string text, Natives.Color startColor
///
/// Generates gradient colored text by interpolating between two System.Drawing colors.
///
- /// The text to apply gradient to.
+ /// The plain text to apply gradient to. Must not contain any HTML tags.
/// The starting System.Drawing color.
/// The ending System.Drawing color.
/// HTML string with each character wrapped in a colored font tag.
@@ -73,7 +73,7 @@ public static string GenerateGradientText( string text, System.Drawing.Color sta
///
/// Generates gradient colored text by interpolating across multiple SwiftlyS2 native color stops.
///
- /// The text to apply gradient to.
+ /// The plain text to apply gradient to. Must not contain any HTML tags.
/// Array of SwiftlyS2 native color stops.
/// HTML string with each character wrapped in a colored font tag.
public static string GenerateGradientText( string text, params Natives.Color[] colors )
@@ -84,7 +84,7 @@ public static string GenerateGradientText( string text, params Natives.Color[] c
///
/// Generates gradient colored text by interpolating across multiple System.Drawing color stops.
///
- /// The text to apply gradient to.
+ /// The plain text to apply gradient to. Must not contain any HTML tags.
/// Array of System.Drawing color stops.
/// HTML string with each character wrapped in a colored font tag.
public static string GenerateGradientText( string text, params System.Drawing.Color[] colors )
@@ -108,9 +108,9 @@ private static string GenerateMultiColorGradient( string text, string[] colors )
var (startR, startG, startB) = parsedColors[startIdx];
var (endR, endG, endB) = parsedColors[endIdx];
- var r = (int)(startR + (endR - startR) * ratio);
- var g = (int)(startG + (endG - startG) * ratio);
- var b = (int)(startB + (endB - startB) * ratio);
+ var r = (int)(startR + ((endR - startR) * ratio));
+ var g = (int)(startG + ((endG - startG) * ratio));
+ var b = (int)(startB + ((endB - startB) * ratio));
return $"{ch}";
}));
diff --git a/managed/src/SwiftlyS2.Shared/Modules/Menus/IMenuAPI.cs b/managed/src/SwiftlyS2.Shared/Modules/Menus/IMenuAPI.cs
index 9d91460d6..ce063c2f0 100644
--- a/managed/src/SwiftlyS2.Shared/Modules/Menus/IMenuAPI.cs
+++ b/managed/src/SwiftlyS2.Shared/Modules/Menus/IMenuAPI.cs
@@ -252,6 +252,11 @@ public interface IMenuAPI : IDisposable
///
public IMenuBuilderAPI? Builder { get; }
+ ///
+ /// Gets or sets the default comment text to use when a menu option's is not set.
+ ///
+ public string DefaultComment { get; set; }
+
///
/// Gets or sets an object that contains data about this menu.
///
diff --git a/managed/src/SwiftlyS2.Shared/Modules/Menus/IMenuOption.cs b/managed/src/SwiftlyS2.Shared/Modules/Menus/IMenuOption.cs
index 71841a2ff..ca5839490 100644
--- a/managed/src/SwiftlyS2.Shared/Modules/Menus/IMenuOption.cs
+++ b/managed/src/SwiftlyS2.Shared/Modules/Menus/IMenuOption.cs
@@ -155,6 +155,14 @@ public interface IMenuOption : IDisposable
///
public string Text { get; set; }
+ ///
+ /// Gets or sets the comment content displayed for this menu option.
+ ///
+ ///
+ /// This is a global property. Changing it will affect what all players see.
+ ///
+ public string Comment { get; set; }
+
///
/// The maximum display width for menu option text in relative units.
///
diff --git a/managed/src/TestPlugin/TestPlugin.cs b/managed/src/TestPlugin/TestPlugin.cs
index 2e86f7677..79116f27f 100644
--- a/managed/src/TestPlugin/TestPlugin.cs
+++ b/managed/src/TestPlugin/TestPlugin.cs
@@ -177,10 +177,10 @@ public override void Load( bool hotReload )
// Console.WriteLine($"PostThink -> {@event.PlayerPawn.OriginalController.Value?.PlayerName}");
// };
- Core.Engine.ExecuteCommandWithBuffer("@ping", ( buffer ) =>
- {
- Console.WriteLine($"pong: {buffer}");
- });
+ // Core.Engine.ExecuteCommandWithBuffer("@ping", ( buffer ) =>
+ // {
+ // Console.WriteLine($"pong: {buffer}");
+ // });
Core.GameEvent.HookPre(@event =>
{
@@ -318,12 +318,12 @@ public override void Load( bool hotReload )
// return HookResult.Continue;
// });
- Core.Event.OnEntityTakeDamage += ( @event ) =>
- {
- Console.WriteLine(@event.Entity.DesignerName);
- @event.Info.DamageFlags = TakeDamageFlags_t.DFLAG_SUPPRESS_BREAKABLES;
- @event.Result = HookResult.Stop;
- };
+ // Core.Event.OnEntityTakeDamage += ( @event ) =>
+ // {
+ // Console.WriteLine(@event.Entity.DesignerName);
+ // @event.Info.DamageFlags = TakeDamageFlags_t.DFLAG_SUPPRESS_BREAKABLES;
+ // @event.Result = HookResult.Stop;
+ // };
// Core.Event.OnTick += () => {
@@ -888,7 +888,7 @@ public void RefactoredMenuTestCommand( ICommandContext context )
.EnableExit()
.SetPlayerFrozen(false)
.Design.SetMaxVisibleItems(5)
- .Design.SetMenuTitle($"{HtmlGradient.GenerateGradientText("Redesigned Menu", "#00FA9A", "#F5FFFA")}")
+ .Design.SetMenuTitle($"{HtmlGradient.GenerateGradientText("SwiftlyS2", "#00FA9A", "#F5FFFA")}")
.Design.SetMenuTitleVisible(true)
.Design.SetMenuFooterVisible(true)
.Design.SetMenuFooterColor("#0F0")
@@ -900,7 +900,7 @@ public void RefactoredMenuTestCommand( ICommandContext context )
.AddOption(new TextMenuOption("1") { Visible = false })
.AddOption(toggle)
.AddOption(new ChoiceMenuOption("123", ["Option 1", "Option 2", "Option 3"]))
- .AddOption(new SliderMenuOption("1234"))
+ .AddOption(new SliderMenuOption("1234") { Comment = "This is a slider" })
.AddOption(new ProgressBarMenuOption("12345", () => (float)new Random().NextDouble(), multiLine: false))
.AddOption(new SubmenuMenuOption("123456", async () =>
{
@@ -912,7 +912,7 @@ public void RefactoredMenuTestCommand( ICommandContext context )
.Build();
return menu;
}))
- .AddOption(new InputMenuOption("1234567"))
+ .AddOption(new SelectorMenuOption(["1234567", "一二三四五六七", "いちにさんよん", "One Two Three", "Один Два Три", "하나 둘 셋", "αβγδεζη"]) { TextStyle = MenuOptionTextStyle.TruncateBothEnds })
.AddOption(new TextMenuOption() { Text = "12345678", TextStyle = MenuOptionTextStyle.ScrollLeftLoop })
.AddOption(new TextMenuOption("123456789"))
.AddOption(new TextMenuOption("1234567890") { Visible = false })
@@ -942,6 +942,7 @@ public void RefactoredMenuTestCommand( ICommandContext context )
.AddOption(new TextMenuOption("1") { Visible = false })
.Build();
+ // menu.DefaultComment = "No specific comment";
Core.MenusAPI.OpenMenu(menu);
// Core.MenusAPI.OpenMenuForPlayer(player, menu);
}