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")]