Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 58 additions & 2 deletions managed/src/SwiftlyS2.Core/Modules/Menus/MenuAPI.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,11 @@ internal sealed class MenuAPI : IMenuAPI, IDisposable
/// </summary>
public IMenuBuilderAPI? Builder { get; init; }

/// <summary>
/// Gets or sets an object that contains data about this menu.
/// </summary>
public object? Tag { get; set; }

/// <summary>
/// The parent hierarchy information in a hierarchical menu structure.
/// </summary>
Expand Down Expand Up @@ -112,6 +117,9 @@ public IReadOnlyList<IMenuOption> Options {
// private readonly ConcurrentDictionary<IPlayer, IReadOnlyList<IMenuOption>> visibleOptionsCache = new();
private readonly ConcurrentDictionary<IPlayer, CancellationTokenSource> autoCloseCancelTokens = new();

private readonly ConcurrentDictionary<IPlayer, string> renderCache = new();
private readonly CancellationTokenSource renderLoopCancellationTokenSource = new();

private volatile bool disposed;

// [SetsRequiredMembers]
Expand All @@ -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()
Expand Down Expand Up @@ -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);
}
Expand All @@ -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)
Expand Down Expand Up @@ -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;
}

Expand All @@ -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);
Expand Down Expand Up @@ -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();
Expand Down
55 changes: 53 additions & 2 deletions managed/src/SwiftlyS2.Core/Modules/Menus/MenuManagerAPI.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
using SwiftlyS2.Shared.Events;
using SwiftlyS2.Shared.Sounds;
using SwiftlyS2.Shared.Players;
using SwiftlyS2.Shared.SchemaDefinitions;

namespace SwiftlyS2.Core.Menus;

Expand All @@ -32,6 +33,7 @@ internal sealed class MenuManagerAPI : IMenuManagerAPI
public event EventHandler<MenuManagerEventArgs>? MenuOpened;

private readonly ConcurrentDictionary<IPlayer, IMenuAPI> openMenus = new();
private readonly ConcurrentDictionary<(IPlayer, IMenuAPI), Action<IPlayer, IMenuAPI>?> onClosedCallbacks = new();
private readonly SoundEvent useSound = new();
private readonly SoundEvent exitSound = new();
private readonly SoundEvent scrollSound = new();
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -295,22 +301,58 @@ 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<IPlayer, IMenuAPI> 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);
menu.ShowForPlayer(player);
MenuOpened?.Invoke(this, new MenuManagerEventArgs { Player = player, Menu = menu });
}

public void OpenMenuForPlayer( IPlayer player, IMenuAPI menu, Action<IPlayer, IMenuAPI> onClosed )
{
OpenMenuForPlayer(player, menu);
_ = onClosedCallbacks.AddOrUpdate((player, menu), onClosed, ( _, _ ) => onClosed);
}

public void CloseMenu( IMenuAPI menu )
{
Core.PlayerManager
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down
5 changes: 5 additions & 0 deletions managed/src/SwiftlyS2.Shared/Modules/Menus/IMenuAPI.cs
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,11 @@ public interface IMenuAPI : IDisposable
/// </summary>
public IMenuBuilderAPI? Builder { get; }

/// <summary>
/// Gets or sets an object that contains data about this menu.
/// </summary>
public object? Tag { get; set; }

/// <summary>
/// The parent hierarchy information in a hierarchical menu structure.
/// </summary>
Expand Down
15 changes: 15 additions & 0 deletions managed/src/SwiftlyS2.Shared/Modules/Menus/IMenuManagerAPI.cs
Original file line number Diff line number Diff line change
Expand Up @@ -144,13 +144,28 @@ public interface IMenuManagerAPI
/// <param name="menu">The menu to display.</param>
public void OpenMenu( IMenuAPI menu );

/// <summary>
/// Opens the specified menu for all players.
/// </summary>
/// <param name="menu">The menu to display.</param>
/// <param name="onClosed">Callback invoked when the menu is closed.</param>
public void OpenMenu( IMenuAPI menu, Action<IPlayer, IMenuAPI> onClosed );

/// <summary>
/// Opens the specified menu for a player. Any currently open menu will be closed first.
/// </summary>
/// <param name="player">The player who will see the menu.</param>
/// <param name="menu">The menu to display.</param>
public void OpenMenuForPlayer( IPlayer player, IMenuAPI menu );

/// <summary>
/// Opens the specified menu for a player. Any currently open menu will be closed first.
/// </summary>
/// <param name="player">The player who will see the menu.</param>
/// <param name="menu">The menu to display.</param>
/// <param name="onClosed">Callback invoked when the menu is closed for the player.</param>
public void OpenMenuForPlayer( IPlayer player, IMenuAPI menu, Action<IPlayer, IMenuAPI> onClosed );

/// <summary>
/// Closes the specified menu for all players who have it open.
/// </summary>
Expand Down
19 changes: 18 additions & 1 deletion managed/src/TestPlugin/TestPlugin.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}
};

Expand All @@ -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")]
Expand Down
Loading