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); }