diff --git a/managed/src/SwiftlyS2.Core/Modules/Menus/MenuAPI.cs b/managed/src/SwiftlyS2.Core/Modules/Menus/MenuAPI.cs index 7ee6e04ef..5a158bd77 100644 --- a/managed/src/SwiftlyS2.Core/Modules/Menus/MenuAPI.cs +++ b/managed/src/SwiftlyS2.Core/Modules/Menus/MenuAPI.cs @@ -41,6 +41,11 @@ internal sealed class MenuAPI : IMenuAPI, IDisposable /// public IMenuBuilderAPI? Builder { get; init; } + /// + /// Gets or sets an object that contains data about this menu. + /// + public object? Tag { get; set; } + /// /// The parent hierarchy information in a hierarchical menu structure. /// @@ -112,6 +117,9 @@ public IReadOnlyList Options { // private readonly ConcurrentDictionary> visibleOptionsCache = new(); private readonly ConcurrentDictionary autoCloseCancelTokens = new(); + private readonly ConcurrentDictionary renderCache = new(); + private readonly CancellationTokenSource renderLoopCancellationTokenSource = new(); + private volatile bool disposed; // [SetsRequiredMembers] @@ -135,11 +143,33 @@ public MenuAPI( ISwiftlyCore core, MenuConfiguration configuration, MenuKeybindO // selectedDisplayLine.Clear(); autoCloseCancelTokens.Clear(); // visibleOptionsCache.Clear(); + renderCache.Clear(); maxOptions = 0; // maxDisplayLines = 0; core.Event.OnTick += OnTick; + + _ = Task.Run(async () => + { + var token = renderLoopCancellationTokenSource.Token; + var delayMilliseconds = (int)(1000f / 64f / 2f); + while (!token.IsCancellationRequested || disposed) + { + try + { + OnRender(); + await Task.Delay(delayMilliseconds, token); + } + catch (OperationCanceledException) + { + break; + } + catch + { + } + } + }, renderLoopCancellationTokenSource.Token); } ~MenuAPI() @@ -177,12 +207,16 @@ public void Dispose() // selectedDisplayLine.Clear(); autoCloseCancelTokens.Clear(); // visibleOptionsCache.Clear(); + renderCache.Clear(); maxOptions = 0; // maxDisplayLines = 0; core.Event.OnTick -= OnTick; + renderLoopCancellationTokenSource?.Cancel(); + renderLoopCancellationTokenSource?.Dispose(); + disposed = true; GC.SuppressFinalize(this); } @@ -194,6 +228,25 @@ private void OnTick() return; } + foreach (var kvp in renderCache) + { + var player = kvp.Key; + if (!player.IsValid || player.IsFakeClient) + { + continue; + } + + NativePlayer.SetCenterMenuRender(player.PlayerID, kvp.Value); + } + } + + private void OnRender() + { + if (maxOptions <= 0) + { + return; + } + var playerStates = core.PlayerManager .GetAllPlayers() .Where(player => player.IsValid && !player.IsFakeClient) @@ -225,7 +278,8 @@ private void ProcessPlayerMenu( IPlayer player, int desiredIndex, int selectedIn var filteredOptions = options.Where(opt => opt.Visible && opt.GetVisible(player)).ToList(); if (filteredOptions.Count == 0) { - NativePlayer.SetCenterMenuRender(player.PlayerID, BuildMenuHtml(player, [], 0, 0, maxOptions, maxVisibleItems)); + var emptyHtml = BuildMenuHtml(player, [], 0, 0, maxOptions, maxVisibleItems); + _ = renderCache.AddOrUpdate(player, emptyHtml, ( _, _ ) => emptyHtml); return; } @@ -239,7 +293,7 @@ private void ProcessPlayerMenu( IPlayer player, int desiredIndex, int selectedIn }); var html = BuildMenuHtml(player, visibleOptions, safeArrowPosition, clampedDesiredIndex, maxOptions, maxVisibleItems); - NativePlayer.SetCenterMenuRender(player.PlayerID, html); + _ = renderCache.AddOrUpdate(player, html, ( _, _ ) => html); var currentOption = visibleOptions[safeArrowPosition]; var currentOriginalIndex = options.IndexOf(currentOption); @@ -413,6 +467,8 @@ public void HideForPlayer( IPlayer player ) SetFreezeState(player, false); + _ = renderCache.TryRemove(player, out _); + if (autoCloseCancelTokens.TryRemove(player, out var token)) { token.Cancel(); diff --git a/managed/src/SwiftlyS2.Core/Modules/Menus/MenuManagerAPI.cs b/managed/src/SwiftlyS2.Core/Modules/Menus/MenuManagerAPI.cs index 236bcf3ce..1e61e5006 100644 --- a/managed/src/SwiftlyS2.Core/Modules/Menus/MenuManagerAPI.cs +++ b/managed/src/SwiftlyS2.Core/Modules/Menus/MenuManagerAPI.cs @@ -6,6 +6,7 @@ using SwiftlyS2.Shared.Events; using SwiftlyS2.Shared.Sounds; using SwiftlyS2.Shared.Players; +using SwiftlyS2.Shared.SchemaDefinitions; namespace SwiftlyS2.Core.Menus; @@ -32,6 +33,7 @@ internal sealed class MenuManagerAPI : IMenuManagerAPI public event EventHandler? MenuOpened; private readonly ConcurrentDictionary openMenus = new(); + private readonly ConcurrentDictionary<(IPlayer, IMenuAPI), Action?> onClosedCallbacks = new(); private readonly SoundEvent useSound = new(); private readonly SoundEvent exitSound = new(); private readonly SoundEvent scrollSound = new(); @@ -95,6 +97,7 @@ public MenuManagerAPI( ISwiftlyCore core ) buttonsUse = StringToKeyBind.GetValueOrDefault(Configuration.ButtonsUse.Trim().ToLower()); openMenus.Clear(); + onClosedCallbacks.Clear(); Core.Event.OnClientKeyStateChanged += KeyStateChange; Core.Event.OnClientDisconnected += OnClientDisconnected; @@ -105,6 +108,9 @@ public MenuManagerAPI( ISwiftlyCore core ) { CloseAllMenus(); + openMenus.Clear(); + onClosedCallbacks.Clear(); + Core.Event.OnClientKeyStateChanged -= KeyStateChange; Core.Event.OnClientDisconnected -= OnClientDisconnected; Core.Event.OnMapUnload -= OnMapUnload; @@ -295,15 +301,45 @@ public void OpenMenu( IMenuAPI menu ) { Core.PlayerManager .GetAllPlayers() + .Where(player => player.IsValid && !player.IsFakeClient && (player.Controller?.IsValid ?? false)) .ToList() .ForEach(player => OpenMenuForPlayer(player, menu)); } + public void OpenMenu( IMenuAPI menu, Action onClosed ) + { + Core.PlayerManager + .GetAllPlayers() + .Where(player => player.IsValid && !player.IsFakeClient && (player.Controller?.IsValid ?? false)) + .ToList() + .ForEach(player => OpenMenuForPlayer(player, menu, onClosed)); + } + public void OpenMenuForPlayer( IPlayer player, IMenuAPI menu ) { - if (GetCurrentMenu(player) != null) + if (openMenus.TryGetValue(player, out var currentMenu) && currentMenu != null) { - CloseMenuForPlayer(player, GetCurrentMenu(player)!); + if (menu.Parent.ParentMenu == currentMenu) + { + // We are transitioning from the current menu to one of its submenus. + // To show the submenu, we first need to close the current (parent) menu, see CloseMenuForPlayer. + // The parent menu may have an onClosed callback registered in onClosedCallbacks. + // If we do not remove that callback temporarily, closing the parent menu here + // would incorrectly invoke the callback even though the user is only navigating + // deeper into the menu hierarchy (parent -> submenu), not actually finishing the overall menu flow. + // Therefore we: + // 1. Temporarily remove the callback associated with the current menu. + // 2. Close the current (parent) menu as part of the navigation process. + // 3. Re-register the callback so it will only be invoked later, when the + // logical end of the menu flow is reached and the menu is truly closed. + _ = onClosedCallbacks.TryRemove((player, currentMenu), out var callback); + CloseMenuForPlayer(player, currentMenu); + _ = onClosedCallbacks.AddOrUpdate((player, currentMenu), callback, ( _, _ ) => callback); + } + else + { + CloseMenuForPlayer(player, currentMenu); + } } _ = openMenus.AddOrUpdate(player, menu, ( _, _ ) => menu); @@ -311,6 +347,12 @@ public void OpenMenuForPlayer( IPlayer player, IMenuAPI menu ) MenuOpened?.Invoke(this, new MenuManagerEventArgs { Player = player, Menu = menu }); } + public void OpenMenuForPlayer( IPlayer player, IMenuAPI menu, Action onClosed ) + { + OpenMenuForPlayer(player, menu); + _ = onClosedCallbacks.AddOrUpdate((player, menu), onClosed, ( _, _ ) => onClosed); + } + public void CloseMenu( IMenuAPI menu ) { Core.PlayerManager @@ -321,6 +363,15 @@ public void CloseMenu( IMenuAPI menu ) public void CloseMenuForPlayer( IPlayer player, IMenuAPI menu ) { + if (!openMenus.TryGetValue(player, out var currentMenu) || currentMenu != menu) + { + return; + } + if (onClosedCallbacks.TryRemove((player, menu), out var onClosed) && onClosed != null) + { + onClosed(player, menu); + } + if (openMenus.TryRemove(player, out _)) { menu.HideForPlayer(player); diff --git a/managed/src/SwiftlyS2.Core/Modules/Menus/OptionsBase/Helpers/DynamicTextUpdater.cs b/managed/src/SwiftlyS2.Core/Modules/Menus/OptionsBase/Helpers/DynamicTextUpdater.cs index 74a8cf955..214439cd6 100644 --- a/managed/src/SwiftlyS2.Core/Modules/Menus/OptionsBase/Helpers/DynamicTextUpdater.cs +++ b/managed/src/SwiftlyS2.Core/Modules/Menus/OptionsBase/Helpers/DynamicTextUpdater.cs @@ -57,7 +57,7 @@ public void Dispose() private async Task UpdateLoopAsync( int intervalMs, int pauseIntervalMs, CancellationToken token ) { - while (!token.IsCancellationRequested) + while (!token.IsCancellationRequested && !disposed) { try { diff --git a/managed/src/SwiftlyS2.Shared/Modules/Menus/IMenuAPI.cs b/managed/src/SwiftlyS2.Shared/Modules/Menus/IMenuAPI.cs index 1b142987d..55fa4454e 100644 --- a/managed/src/SwiftlyS2.Shared/Modules/Menus/IMenuAPI.cs +++ b/managed/src/SwiftlyS2.Shared/Modules/Menus/IMenuAPI.cs @@ -247,6 +247,11 @@ public interface IMenuAPI : IDisposable /// public IMenuBuilderAPI? Builder { get; } + /// + /// Gets or sets an object that contains data about this menu. + /// + public object? Tag { get; set; } + /// /// The parent hierarchy information in a hierarchical menu structure. /// diff --git a/managed/src/SwiftlyS2.Shared/Modules/Menus/IMenuManagerAPI.cs b/managed/src/SwiftlyS2.Shared/Modules/Menus/IMenuManagerAPI.cs index bb27b38f6..0ec4d7f81 100644 --- a/managed/src/SwiftlyS2.Shared/Modules/Menus/IMenuManagerAPI.cs +++ b/managed/src/SwiftlyS2.Shared/Modules/Menus/IMenuManagerAPI.cs @@ -144,6 +144,13 @@ public interface IMenuManagerAPI /// The menu to display. public void OpenMenu( IMenuAPI menu ); + /// + /// Opens the specified menu for all players. + /// + /// The menu to display. + /// Callback invoked when the menu is closed. + public void OpenMenu( IMenuAPI menu, Action onClosed ); + /// /// Opens the specified menu for a player. Any currently open menu will be closed first. /// @@ -151,6 +158,14 @@ public interface IMenuManagerAPI /// The menu to display. public void OpenMenuForPlayer( IPlayer player, IMenuAPI menu ); + /// + /// Opens the specified menu for a player. Any currently open menu will be closed first. + /// + /// The player who will see the menu. + /// The menu to display. + /// Callback invoked when the menu is closed for the player. + public void OpenMenuForPlayer( IPlayer player, IMenuAPI menu, Action onClosed ); + /// /// Closes the specified menu for all players who have it open. /// diff --git a/managed/src/TestPlugin/TestPlugin.cs b/managed/src/TestPlugin/TestPlugin.cs index a0b6ce996..d29722c08 100644 --- a/managed/src/TestPlugin/TestPlugin.cs +++ b/managed/src/TestPlugin/TestPlugin.cs @@ -634,6 +634,7 @@ public void TestMenuCommand( ICommandContext context ) var triggerOption = option!.Menu!.Parent.TriggerOption; triggerOption!.Enabled = false; args.Player.SendChat($"Purchase completed -> {triggerOption!.Text}"); + // option!.Menu!.Tag = ("Purchase", System.Diagnostics.Stopwatch.GetTimestamp()); } }; @@ -653,7 +654,23 @@ public void TestMenuCommand( ICommandContext context ) .AddOption(new SubmenuMenuOption("Item 4", confirmMenu)) .Build(); - Core.MenusAPI.OpenMenuForPlayer(context.Sender!, menu); + Core.MenusAPI.OpenMenu(menu, ( player, menu ) => + { + Console.WriteLine($"{menu.Configuration.Title} closed for player: {player.Controller.PlayerName}"); + }); + + // Core.MenusAPI.OpenMenuForPlayer(context.Sender!, menu, ( player, menu ) => + // { + // Console.WriteLine($"{menu.Configuration.Title} closed for player: {player.Controller.PlayerName}"); + // }); + + // Core.MenusAPI.MenuClosed += ( sender, args ) => + // { + // if (args.Menu?.Tag is (string, long)) + // { + // Console.WriteLine($"Purchase completed -> {args.Menu.Tag}"); + // } + // }; } [Command("rmt")]