From c5a002153306fd02fb4c78e97d899cda388ca67d Mon Sep 17 00:00:00 2001 From: Ambr0se Date: Fri, 21 Nov 2025 13:04:47 +0800 Subject: [PATCH 1/9] refactor: Register MenuManagerAPI as global singleton --- managed/src/SwiftlyS2.Core/Bootstrap.cs | 159 +++++++++--------- .../Hosting/MenuManagerAPIInjection.cs | 12 ++ .../Hosting/MenuManagerAPIServiceInjection.cs | 17 ++ .../Modules/Menus/MenuManagerAPI.cs | 37 ++-- .../Modules/Plugins/SwiftlyCore.cs | 4 +- .../Services/MenuManagerAPIService.cs | 28 +++ .../SwiftlyS2.Core/Services/StartupService.cs | 44 ++--- managed/src/TestPlugin/TestPlugin.cs | 2 +- 8 files changed, 177 insertions(+), 126 deletions(-) create mode 100644 managed/src/SwiftlyS2.Core/Hosting/MenuManagerAPIInjection.cs create mode 100644 managed/src/SwiftlyS2.Core/Hosting/MenuManagerAPIServiceInjection.cs create mode 100644 managed/src/SwiftlyS2.Core/Services/MenuManagerAPIService.cs diff --git a/managed/src/SwiftlyS2.Core/Bootstrap.cs b/managed/src/SwiftlyS2.Core/Bootstrap.cs index a471357f1..967655359 100644 --- a/managed/src/SwiftlyS2.Core/Bootstrap.cs +++ b/managed/src/SwiftlyS2.Core/Bootstrap.cs @@ -1,102 +1,97 @@ +using System.Reflection; +using System.Runtime.InteropServices; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Spectre.Console; +using SwiftlyS2.Core.Misc; +using SwiftlyS2.Core.Events; using SwiftlyS2.Core.Hosting; using SwiftlyS2.Core.Natives; using SwiftlyS2.Core.Services; -using SwiftlyS2.Shared; -using SwiftlyS2.Core.Events; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Hosting; -using SwiftlyS2.Core.Misc; -using Microsoft.Extensions.Configuration; -using SwiftlyS2.Shared.Memory; -using SwiftlyS2.Shared.Services; -using System.Runtime.InteropServices; using SwiftlyS2.Shared.SteamAPI; -using System.Reflection; + namespace SwiftlyS2.Core; internal static class Bootstrap { + // how tf i forgot services can be collected hahahahahahahhaahhahaa FUCK + private static IHost? sw2Host; - // how tf i forgot services can be collected hahahahahahahhaahhahaa FUCK - private static IHost? _host; - - private static IntPtr SteamAPIDLLResolver( string libraryName, Assembly assembly, DllImportSearchPath? searchPath ) - { - if (libraryName == "steam_api" || libraryName == "sdkencryptedappticket") + private static IntPtr SteamAPIDLLResolver( string libraryName, Assembly assembly, DllImportSearchPath? searchPath ) { - if (OperatingSystem.IsWindows()) - { - libraryName += "64"; - } - - if (NativeLibrary.TryLoad(libraryName, out var handle)) - { - return handle; - } - } + if (libraryName == "steam_api" || libraryName == "sdkencryptedappticket") + { + if (OperatingSystem.IsWindows()) + { + libraryName += "64"; + } - return IntPtr.Zero; - } + if (NativeLibrary.TryLoad(libraryName, out var handle)) + { + return handle; + } + } - public static void Start( IntPtr nativeTable, int nativeTableSize, string basePath ) - { - Environment.SetEnvironmentVariable("SWIFTLY_MANAGED_ROOT", basePath); - - NativeBinding.BindNatives(nativeTable, nativeTableSize); - - NativeLibrary.SetDllImportResolver(typeof(NativeMethods).Assembly, SteamAPIDLLResolver); - - EventPublisher.Register(); - GameFunctions.Initialize(); - FileLogger.Initialize(basePath); + return IntPtr.Zero; + } - AnsiConsole.Write(new FigletText("SwiftlyS2").LeftJustified().Color(Spectre.Console.Color.LightSteelBlue1)); + public static void Start( IntPtr nativeTable, int nativeTableSize, string basePath ) + { + Environment.SetEnvironmentVariable("SWIFTLY_MANAGED_ROOT", basePath); + NativeBinding.BindNatives(nativeTable, nativeTableSize); + NativeLibrary.SetDllImportResolver(typeof(NativeMethods).Assembly, SteamAPIDLLResolver); - _host = Host.CreateDefaultBuilder() - .UseConsoleLifetime(options => - { - options.SuppressStatusMessages = true; - }) - .ConfigureServices(( context, services ) => - { - services.AddHostedService(); - }) - .ConfigureLogging(( context, logging ) => - { - logging.ClearProviders(); - logging.AddProvider(new SwiftlyLoggerProvider("SwiftlyS2")); - }) - .ConfigureAppConfiguration(( context, config ) => - { - config.SetBasePath(Path.Combine(Environment.GetEnvironmentVariable("SWIFTLY_MANAGED_ROOT")!, "configs")); - config.AddJsonFile("permissions.jsonc", optional: false, reloadOnChange: true); - }) - .ConfigureServices(( context, services ) => - { - services - .AddProfileService() - .AddConfigurationService() - .AddTestService() - .AddRootDirService() - .AddDataDirectoryService() - .AddPlayerManagerService() - .AddPluginManager() - .AddHookManager() - .AddTraceManagerService() - .AddPermissionManager() - .AddCoreHookService() - .AddCoreCommandService() - .AddCommandTrackerManager() - .AddCommandTrackerService() - .AddSwiftlyCore(basePath); - }) - .Build(); + EventPublisher.Register(); + GameFunctions.Initialize(); + FileLogger.Initialize(basePath); - _host.Start(); + AnsiConsole.Write(new FigletText("SwiftlyS2").LeftJustified().Color(Color.LightSteelBlue1)); - // provider.UseTestService(); + sw2Host = Host.CreateDefaultBuilder() + .UseConsoleLifetime(options => + { + options.SuppressStatusMessages = true; + }) + .ConfigureServices(( context, services ) => + { + _ = services.AddHostedService(); + }) + .ConfigureLogging(( context, logging ) => + { + _ = logging.ClearProviders(); + _ = logging.AddProvider(new SwiftlyLoggerProvider("SwiftlyS2")); + }) + .ConfigureAppConfiguration(( context, config ) => + { + _ = config.SetBasePath(Path.Combine(Environment.GetEnvironmentVariable("SWIFTLY_MANAGED_ROOT")!, "configs")); + _ = config.AddJsonFile("permissions.jsonc", optional: false, reloadOnChange: true); + }) + .ConfigureServices(( context, services ) => + { + _ = services + .AddProfileService() + .AddConfigurationService() + .AddTestService() + .AddRootDirService() + .AddDataDirectoryService() + .AddPlayerManagerService() + .AddPluginManager() + .AddHookManager() + .AddTraceManagerService() + .AddPermissionManager() + .AddCoreHookService() + .AddCoreCommandService() + .AddCommandTrackerManager() + .AddCommandTrackerService() + .AddMenuManagerAPI() + .AddMenuManagerAPIService() + .AddSwiftlyCore(basePath); + }) + .Build(); - } + sw2Host.Start(); + // provider.UseTestService(); + } } \ No newline at end of file diff --git a/managed/src/SwiftlyS2.Core/Hosting/MenuManagerAPIInjection.cs b/managed/src/SwiftlyS2.Core/Hosting/MenuManagerAPIInjection.cs new file mode 100644 index 000000000..6237d971b --- /dev/null +++ b/managed/src/SwiftlyS2.Core/Hosting/MenuManagerAPIInjection.cs @@ -0,0 +1,12 @@ +using Microsoft.Extensions.DependencyInjection; +using SwiftlyS2.Core.Menus; + +namespace SwiftlyS2.Core.Hosting; + +internal static class MenuManagerAPIInjection +{ + public static IServiceCollection AddMenuManagerAPI( this IServiceCollection self ) + { + return self.AddSingleton(); + } +} \ No newline at end of file diff --git a/managed/src/SwiftlyS2.Core/Hosting/MenuManagerAPIServiceInjection.cs b/managed/src/SwiftlyS2.Core/Hosting/MenuManagerAPIServiceInjection.cs new file mode 100644 index 000000000..70058e340 --- /dev/null +++ b/managed/src/SwiftlyS2.Core/Hosting/MenuManagerAPIServiceInjection.cs @@ -0,0 +1,17 @@ +using Microsoft.Extensions.DependencyInjection; +using SwiftlyS2.Core.Services; + +namespace SwiftlyS2.Core.Hosting; + +internal static class MenuManagerAPIServiceInjection +{ + public static IServiceCollection AddMenuManagerAPIService( this IServiceCollection self ) + { + return self.AddSingleton(); + } + + public static void UseMenuManagerAPIService( this IServiceProvider self ) + { + self.GetRequiredService(); + } +} \ 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 85f95a964..d1707534c 100644 --- a/managed/src/SwiftlyS2.Core/Modules/Menus/MenuManagerAPI.cs +++ b/managed/src/SwiftlyS2.Core/Modules/Menus/MenuManagerAPI.cs @@ -1,12 +1,11 @@ using System.Globalization; using System.Collections.Concurrent; -using SwiftlyS2.Core.Natives; using SwiftlyS2.Shared; +using SwiftlyS2.Core.Natives; using SwiftlyS2.Shared.Menus; using SwiftlyS2.Shared.Events; using SwiftlyS2.Shared.Sounds; using SwiftlyS2.Shared.Players; -using SwiftlyS2.Shared.SchemaDefinitions; namespace SwiftlyS2.Core.Menus; @@ -15,7 +14,7 @@ internal sealed class MenuManagerAPI : IMenuManagerAPI /// /// The SwiftlyS2 core instance. /// - public ISwiftlyCore Core { get; init; } + public ISwiftlyCore Core { get; internal set; } = default!; /// /// Global Configuration settings for all menus. @@ -64,10 +63,8 @@ internal sealed class MenuManagerAPI : IMenuManagerAPI ["f"] = KeyBind.F, }; - public MenuManagerAPI( ISwiftlyCore core ) + public MenuManagerAPI() { - Core = core; - var settings = NativeEngineHelpers.GetMenuSettings().Trim().Split('\x01'); Configuration = new MenuManagerConfiguration { NavigationPrefix = settings[0], @@ -98,10 +95,6 @@ public MenuManagerAPI( ISwiftlyCore core ) openMenus.Clear(); onClosedCallbacks.Clear(); - - Core.Event.OnClientKeyStateChanged += KeyStateChange; - Core.Event.OnClientDisconnected += OnClientDisconnected; - Core.Event.OnMapUnload += OnMapUnload; } ~MenuManagerAPI() @@ -110,13 +103,9 @@ public MenuManagerAPI( ISwiftlyCore core ) openMenus.Clear(); onClosedCallbacks.Clear(); - - Core.Event.OnClientKeyStateChanged -= KeyStateChange; - Core.Event.OnClientDisconnected -= OnClientDisconnected; - Core.Event.OnMapUnload -= OnMapUnload; } - private void KeyStateChange( IOnClientKeyStateChangedEvent @event ) + internal void OnClientKeyStateChanged( IOnClientKeyStateChangedEvent @event ) { if (openMenus.IsEmpty) { @@ -239,19 +228,29 @@ private void KeyStateChange( IOnClientKeyStateChangedEvent @event ) } } - private void OnClientDisconnected( IOnClientDisconnectedEvent @event ) + internal void OnClientDisconnected( IOnClientDisconnectedEvent @event ) { + var stackTrace = new System.Diagnostics.StackTrace(true); var player = Core.PlayerManager.GetPlayer(@event.PlayerId); if (player != null) { + Console.WriteLine($"[{DateTime.Now:HH:mm:ss.fff}] OnClientDisconnected {player.Controller?.PlayerName} (PlayerID: {player.PlayerID})"); + Console.WriteLine($"Total openMenus count: {openMenus.Count}"); + Console.WriteLine("=== All openMenus mappings ==="); + foreach (var kvp in openMenus) + { + Console.WriteLine($"Player: {kvp.Key.Controller?.PlayerName} (ID: {kvp.Key.PlayerID}) -> Menu: {kvp.Value.Configuration.Title}"); + } + Console.WriteLine("=============================="); + Console.WriteLine($"Stack trace: {stackTrace}"); openMenus .Where(kvp => kvp.Key == player) .ToList() - .ForEach(kvp => CloseMenuForPlayerInternal(player, kvp.Value, true)); + .ForEach(kvp => CloseMenuForPlayerInternal(kvp.Key, kvp.Value, false)); } } - private void OnMapUnload( IOnMapUnloadEvent _ ) + internal void OnMapUnload( IOnMapUnloadEvent _ ) { CloseAllMenus(); } @@ -383,6 +382,8 @@ public void CloseAllMenus() private void CloseMenuForPlayerInternal( IPlayer player, IMenuAPI menu, bool reopenParent ) { + Console.WriteLine($"CloseMenuForPlayerInternal {player.Controller?.PlayerName} {menu.Configuration.Title}"); + if (!openMenus.TryGetValue(player, out var currentMenu) || currentMenu != menu) { return; diff --git a/managed/src/SwiftlyS2.Core/Modules/Plugins/SwiftlyCore.cs b/managed/src/SwiftlyS2.Core/Modules/Plugins/SwiftlyCore.cs index 886ae14e6..dbb58bb81 100644 --- a/managed/src/SwiftlyS2.Core/Modules/Plugins/SwiftlyCore.cs +++ b/managed/src/SwiftlyS2.Core/Modules/Plugins/SwiftlyCore.cs @@ -97,6 +97,7 @@ public SwiftlyCore( string contextId, string contextBaseDirectory, PluginMetadat .AddSingleton(coreProvider.GetRequiredService()) .AddSingleton(coreProvider.GetRequiredService()) .AddSingleton(coreProvider.GetRequiredService()) + .AddSingleton(coreProvider.GetRequiredService()) .AddSingleton() .AddSingleton() @@ -118,12 +119,10 @@ public SwiftlyCore( string contextId, string contextBaseDirectory, PluginMetadat .AddSingleton(provider => provider.GetRequiredService().GetLocalizer()) .AddSingleton() // .AddSingleton() - .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton(provider => provider.GetRequiredService()) - .AddSingleton(provider => provider.GetRequiredService()) .AddSingleton(provider => provider.GetRequiredService()) .AddSingleton(provider => provider.GetRequiredService()) @@ -151,7 +150,6 @@ public SwiftlyCore( string contextId, string contextBaseDirectory, PluginMetadat .AddSingleton(provider => provider.GetRequiredService()) .AddLogging(builder => builder.AddProvider(new SwiftlyLoggerProvider(id.Name))) - .BuildServiceProvider(); EventSubscriber = serviceProvider.GetRequiredService(); diff --git a/managed/src/SwiftlyS2.Core/Services/MenuManagerAPIService.cs b/managed/src/SwiftlyS2.Core/Services/MenuManagerAPIService.cs new file mode 100644 index 000000000..7c419eec9 --- /dev/null +++ b/managed/src/SwiftlyS2.Core/Services/MenuManagerAPIService.cs @@ -0,0 +1,28 @@ +using SwiftlyS2.Shared; +using SwiftlyS2.Core.Menus; + +namespace SwiftlyS2.Core.Services; + +internal class MenuManagerAPIService : IDisposable +{ + private readonly ISwiftlyCore core; + private readonly MenuManagerAPI menuManager; + + public MenuManagerAPIService( ISwiftlyCore core, MenuManagerAPI menuManager ) + { + this.core = core; + this.menuManager = menuManager; + + menuManager.Core = core; + core.Event.OnClientKeyStateChanged += menuManager.OnClientKeyStateChanged; + core.Event.OnClientDisconnected += menuManager.OnClientDisconnected; + core.Event.OnMapUnload += menuManager.OnMapUnload; + } + + public void Dispose() + { + core.Event.OnClientKeyStateChanged -= menuManager.OnClientKeyStateChanged; + core.Event.OnClientDisconnected -= menuManager.OnClientDisconnected; + core.Event.OnMapUnload -= menuManager.OnMapUnload; + } +} \ No newline at end of file diff --git a/managed/src/SwiftlyS2.Core/Services/StartupService.cs b/managed/src/SwiftlyS2.Core/Services/StartupService.cs index fa86dc966..393732e63 100644 --- a/managed/src/SwiftlyS2.Core/Services/StartupService.cs +++ b/managed/src/SwiftlyS2.Core/Services/StartupService.cs @@ -1,33 +1,33 @@ using Microsoft.Extensions.Hosting; -using SwiftlyS2.Core.Hosting; using SwiftlyS2.Core.Misc; +using SwiftlyS2.Core.Hosting; namespace SwiftlyS2.Core.Services; internal class StartupService : IHostedService { + // private readonly IServiceProvider _provider; - private readonly IServiceProvider _provider; - - public StartupService(IServiceProvider provider) - { - _provider = provider; - provider.UseCoreCommandService(); - provider.UseCoreHookService(); - provider.UsePermissionManager(); - provider.UsePluginManager(); - provider.UseCommandTrackerService(); - // provider.UseTestService(); - } + public StartupService( IServiceProvider provider ) + { + // _provider = provider; + provider.UseCoreCommandService(); + provider.UseCoreHookService(); + provider.UsePermissionManager(); + provider.UsePluginManager(); + provider.UseCommandTrackerService(); + provider.UseMenuManagerAPIService(); + // provider.UseTestService(); + } - public Task StartAsync(CancellationToken cancellationToken) - { - return Task.CompletedTask; - } + public Task StartAsync( CancellationToken cancellationToken ) + { + return Task.CompletedTask; + } - public Task StopAsync(CancellationToken cancellationToken) - { - FileLogger.Dispose(); - return Task.CompletedTask; - } + public Task StopAsync( CancellationToken cancellationToken ) + { + FileLogger.Dispose(); + return Task.CompletedTask; + } } \ No newline at end of file diff --git a/managed/src/TestPlugin/TestPlugin.cs b/managed/src/TestPlugin/TestPlugin.cs index 3170103cb..e2604f2bf 100644 --- a/managed/src/TestPlugin/TestPlugin.cs +++ b/managed/src/TestPlugin/TestPlugin.cs @@ -639,7 +639,7 @@ public void GetIpCommand( ICommandContext context ) // } [Command("ed")] - public void EndRoundCommand( ICommandContext _ ) + public void EmitGrenadeCommand( ICommandContext _ ) { var smoke = CSmokeGrenadeProjectile.EmitGrenade(new(0, 0, 0), new(0, 0, 0), new(0, 0, 0), Team.CT, null); } From 28240168ec824fc50cf187f41ecf88f3915e7c51 Mon Sep 17 00:00:00 2001 From: Ambr0se Date: Fri, 21 Nov 2025 13:57:55 +0800 Subject: [PATCH 2/9] fix: Replace IPlayer keys with PlayerID in dictionaries to avoid reference comparison issues --- .../SwiftlyS2.Core/Modules/Menus/MenuAPI.cs | 46 ++++++++--------- .../Modules/Menus/MenuManagerAPI.cs | 50 +++++++++---------- .../Menus/OptionsBase/ChoiceMenuOption.cs | 10 ++-- .../Menus/OptionsBase/InputMenuOption.cs | 46 ++++++++--------- .../Menus/OptionsBase/MenuOptionBase.cs | 24 ++++----- .../OptionsBase/ProgressBarMenuOption.cs | 8 +-- .../Menus/OptionsBase/SliderMenuOption.cs | 12 ++--- .../Menus/OptionsBase/ToggleMenuOption.cs | 10 ++-- 8 files changed, 103 insertions(+), 103 deletions(-) diff --git a/managed/src/SwiftlyS2.Core/Modules/Menus/MenuAPI.cs b/managed/src/SwiftlyS2.Core/Modules/Menus/MenuAPI.cs index af9dcdb28..07e8d3311 100644 --- a/managed/src/SwiftlyS2.Core/Modules/Menus/MenuAPI.cs +++ b/managed/src/SwiftlyS2.Core/Modules/Menus/MenuAPI.cs @@ -108,16 +108,16 @@ public IReadOnlyList Options { private readonly ISwiftlyCore core; private readonly List options = new(); private readonly Lock optionsLock = new(); // Lock for synchronizing modifications to the `options` - private readonly ConcurrentDictionary selectedOptionIndex = new(); // Stores the currently selected option index for each player + private readonly ConcurrentDictionary selectedOptionIndex = new(); // Stores the currently selected option index for each player // NOTE: Menu selection movement is entirely driven by changes to `desiredOptionIndex` (independent of any other variables) - private readonly ConcurrentDictionary desiredOptionIndex = new(); // Stores the desired option index for each player + private readonly ConcurrentDictionary desiredOptionIndex = new(); // Stores the desired option index for each player private int maxOptions = 0; - // private readonly ConcurrentDictionary selectedDisplayLine = new(); // Stores the currently selected display line index for each player (some options may span multiple lines) + // private readonly ConcurrentDictionary selectedDisplayLine = new(); // Stores the currently selected display line index for each player (some options may span multiple lines) // private int maxDisplayLines = 0; - // private readonly ConcurrentDictionary> visibleOptionsCache = new(); - private readonly ConcurrentDictionary autoCloseCancelTokens = new(); + // private readonly ConcurrentDictionary> visibleOptionsCache = new(); + private readonly ConcurrentDictionary autoCloseCancelTokens = new(); - // private readonly ConcurrentDictionary renderCache = new(); + // private readonly ConcurrentDictionary renderCache = new(); private readonly CancellationTokenSource renderLoopCancellationTokenSource = new(); private volatile bool disposed; @@ -190,13 +190,13 @@ public void Dispose() // Console.WriteLine($"{GetType().Name} has been disposed."); core.PlayerManager .GetAllPlayers() - .Where(player => player.IsValid && (selectedOptionIndex.TryGetValue(player, out var _) || desiredOptionIndex.TryGetValue(player, out var _))) + .Where(player => player.IsValid && (selectedOptionIndex.TryGetValue(player.PlayerID, out var _) || desiredOptionIndex.TryGetValue(player.PlayerID, out var _))) .ToList() .ForEach(player => { NativePlayer.ClearCenterMenuRender(player.PlayerID); SetFreezeState(player, false); - if (autoCloseCancelTokens.TryGetValue(player, out var token)) + if (autoCloseCancelTokens.TryGetValue(player.PlayerID, out var token)) { token.Cancel(); token.Dispose(); @@ -258,8 +258,8 @@ private void OnRender() .Where(player => player.IsValid && !player.IsFakeClient) .Select(player => ( Player: player, - DesiredIndex: desiredOptionIndex.TryGetValue(player, out var desired) ? desired : -1, - SelectedIndex: selectedOptionIndex.TryGetValue(player, out var selected) ? selected : -1 + DesiredIndex: desiredOptionIndex.TryGetValue(player.PlayerID, out var desired) ? desired : -1, + SelectedIndex: selectedOptionIndex.TryGetValue(player.PlayerID, out var selected) ? selected : -1 )) .Where(state => state.DesiredIndex >= 0 && state.SelectedIndex >= 0) .ToList(); @@ -311,10 +311,10 @@ private void ProcessPlayerMenu( IPlayer player, int desiredIndex, int selectedIn if (currentOriginalIndex != selectedIndex) { - var updateResult = selectedOptionIndex.TryUpdate(player, currentOriginalIndex, selectedIndex); + var updateResult = selectedOptionIndex.TryUpdate(player.PlayerID, currentOriginalIndex, selectedIndex); if (updateResult && currentOriginalIndex != desiredIndex) { - _ = desiredOptionIndex.TryUpdate(player, currentOriginalIndex, desiredIndex); + _ = desiredOptionIndex.TryUpdate(player.PlayerID, currentOriginalIndex, desiredIndex); } } } @@ -438,8 +438,8 @@ private string BuildMenuHtml( IPlayer player, IReadOnlyList visible public void ShowForPlayer( IPlayer player ) { - _ = selectedOptionIndex.AddOrUpdate(player, 0, ( _, _ ) => 0); - _ = desiredOptionIndex.AddOrUpdate(player, 0, ( _, _ ) => 0); + _ = selectedOptionIndex.AddOrUpdate(player.PlayerID, 0, ( _, _ ) => 0); + _ = desiredOptionIndex.AddOrUpdate(player.PlayerID, 0, ( _, _ ) => 0); // _ = selectedDisplayLine.AddOrUpdate(player, 0, ( _, _ ) => 0); if (!player.IsValid || player.IsFakeClient) @@ -452,7 +452,7 @@ public void ShowForPlayer( IPlayer player ) if (Configuration.AutoCloseAfter > 0) { _ = autoCloseCancelTokens.AddOrUpdate( - player, + player.PlayerID, _ => core.Scheduler.DelayBySeconds(Configuration.AutoCloseAfter, () => core.MenusAPI.CloseMenuForPlayer(player, this)), ( _, oldToken ) => { @@ -466,12 +466,12 @@ public void ShowForPlayer( IPlayer player ) public void HideForPlayer( IPlayer player ) { - var removedFromSelected = selectedOptionIndex.TryRemove(player, out _); - var removedFromDesired = desiredOptionIndex.TryRemove(player, out _); + var removedFromSelected = selectedOptionIndex.TryRemove(player.PlayerID, out _); + var removedFromDesired = desiredOptionIndex.TryRemove(player.PlayerID, out _); // var removedFromDisplayLine = selectedDisplayLine.TryRemove(player, out _); var keyExists = removedFromSelected || removedFromDesired/* || removedFromDisplayLine*/; - if (!player.IsValid || player.IsFakeClient) + if (player.IsFakeClient || !(player.Controller?.IsValid ?? false) || !(player.PlayerPawn?.IsValid ?? false)) { return; } @@ -486,7 +486,7 @@ public void HideForPlayer( IPlayer player ) // _ = renderCache.TryRemove(player, out _); - if (autoCloseCancelTokens.TryRemove(player, out var token)) + if (autoCloseCancelTokens.TryRemove(player.PlayerID, out var token)) { token.Cancel(); token.Dispose(); @@ -547,7 +547,7 @@ public bool MoveToOption( IPlayer player, IMenuOption option ) public bool MoveToOptionIndex( IPlayer player, int index ) { - if (maxOptions == 0 || !desiredOptionIndex.TryGetValue(player, out var oldIndex)) + if (maxOptions == 0 || !desiredOptionIndex.TryGetValue(player.PlayerID, out var oldIndex)) { return false; } @@ -565,7 +565,7 @@ public bool MoveToOptionIndex( IPlayer player, int index ) .Select(i => (((targetIndex + (i * direction)) % maxOptions) + maxOptions) % maxOptions) .FirstOrDefault(idx => options[idx].Visible && options[idx].GetVisible(player), -1); - return visibleIndex >= 0 && desiredOptionIndex.TryUpdate(player, visibleIndex, oldIndex); + return visibleIndex >= 0 && desiredOptionIndex.TryUpdate(player.PlayerID, visibleIndex, oldIndex); } } @@ -573,13 +573,13 @@ public bool MoveToOptionIndex( IPlayer player, int index ) { lock (optionsLock) { - return selectedOptionIndex.TryGetValue(player, out var index) ? options[index] : null; + return selectedOptionIndex.TryGetValue(player.PlayerID, out var index) ? options[index] : null; } } public int GetCurrentOptionIndex( IPlayer player ) { - return selectedOptionIndex.TryGetValue(player, out var index) ? index : -1; + return selectedOptionIndex.TryGetValue(player.PlayerID, out var index) ? index : -1; } // public int GetCurrentOptionDisplayLine( IPlayer player ) diff --git a/managed/src/SwiftlyS2.Core/Modules/Menus/MenuManagerAPI.cs b/managed/src/SwiftlyS2.Core/Modules/Menus/MenuManagerAPI.cs index d1707534c..5a23d8afb 100644 --- a/managed/src/SwiftlyS2.Core/Modules/Menus/MenuManagerAPI.cs +++ b/managed/src/SwiftlyS2.Core/Modules/Menus/MenuManagerAPI.cs @@ -31,8 +31,8 @@ internal sealed class MenuManagerAPI : IMenuManagerAPI /// public event EventHandler? MenuOpened; - private readonly ConcurrentDictionary openMenus = new(); - private readonly ConcurrentDictionary<(IPlayer, IMenuAPI), Action?> onClosedCallbacks = new(); + private readonly ConcurrentDictionary openMenus = new(); + private readonly ConcurrentDictionary<(int, IMenuAPI), Action?> onClosedCallbacks = new(); private readonly SoundEvent useSound = new(); private readonly SoundEvent exitSound = new(); private readonly SoundEvent scrollSound = new(); @@ -234,19 +234,10 @@ internal void OnClientDisconnected( IOnClientDisconnectedEvent @event ) var player = Core.PlayerManager.GetPlayer(@event.PlayerId); if (player != null) { - Console.WriteLine($"[{DateTime.Now:HH:mm:ss.fff}] OnClientDisconnected {player.Controller?.PlayerName} (PlayerID: {player.PlayerID})"); - Console.WriteLine($"Total openMenus count: {openMenus.Count}"); - Console.WriteLine("=== All openMenus mappings ==="); - foreach (var kvp in openMenus) - { - Console.WriteLine($"Player: {kvp.Key.Controller?.PlayerName} (ID: {kvp.Key.PlayerID}) -> Menu: {kvp.Value.Configuration.Title}"); - } - Console.WriteLine("=============================="); - Console.WriteLine($"Stack trace: {stackTrace}"); openMenus - .Where(kvp => kvp.Key == player) + .Where(kvp => kvp.Key == player.PlayerID) .ToList() - .ForEach(kvp => CloseMenuForPlayerInternal(kvp.Key, kvp.Value, false)); + .ForEach(kvp => CloseMenuForPlayerInternal(player, kvp.Value, false)); } } @@ -293,7 +284,7 @@ public IMenuAPI CreateMenu( MenuConfiguration configuration, MenuKeybindOverride public IMenuAPI? GetCurrentMenu( IPlayer player ) { - return openMenus.TryGetValue(player, out var menu) ? menu : null; + return openMenus.TryGetValue(player.PlayerID, out var menu) ? menu : null; } public void OpenMenu( IMenuAPI menu ) @@ -316,7 +307,12 @@ public void OpenMenu( IMenuAPI menu, Action onClosed ) public void OpenMenuForPlayer( IPlayer player, IMenuAPI menu ) { - if (openMenus.TryGetValue(player, out var currentMenu) && currentMenu != null) + if (!player.IsValid || player.IsFakeClient || !(player.Controller?.IsValid ?? false)) + { + return; + } + + if (openMenus.TryGetValue(player.PlayerID, out var currentMenu) && currentMenu != null) { if (menu.Parent.ParentMenu == currentMenu) { @@ -331,9 +327,9 @@ public void OpenMenuForPlayer( IPlayer player, IMenuAPI 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); + _ = onClosedCallbacks.TryRemove((player.PlayerID, currentMenu), out var callback); CloseMenuForPlayerInternal(player, currentMenu, false); - _ = onClosedCallbacks.AddOrUpdate((player, currentMenu), callback, ( _, _ ) => callback); + _ = onClosedCallbacks.AddOrUpdate((player.PlayerID, currentMenu), callback, ( _, _ ) => callback); } else { @@ -341,7 +337,7 @@ public void OpenMenuForPlayer( IPlayer player, IMenuAPI menu ) } } - _ = openMenus.AddOrUpdate(player, menu, ( _, _ ) => menu); + _ = openMenus.AddOrUpdate(player.PlayerID, menu, ( _, _ ) => menu); menu.ShowForPlayer(player); MenuOpened?.Invoke(this, new MenuManagerEventArgs { Player = player, Menu = menu }); } @@ -349,7 +345,7 @@ public void OpenMenuForPlayer( IPlayer player, IMenuAPI menu ) public void OpenMenuForPlayer( IPlayer player, IMenuAPI menu, Action onClosed ) { OpenMenuForPlayer(player, menu); - _ = onClosedCallbacks.AddOrUpdate((player, menu), onClosed, ( _, _ ) => onClosed); + _ = onClosedCallbacks.AddOrUpdate((player.PlayerID, menu), onClosed, ( _, _ ) => onClosed); } public void CloseMenu( IMenuAPI menu ) @@ -372,8 +368,9 @@ public void CloseAllMenus() var currentMenu = kvp.Value; while (currentMenu != null) { - currentMenu.HideForPlayer(kvp.Key); - MenuClosed?.Invoke(this, new MenuManagerEventArgs { Player = kvp.Key, Menu = currentMenu }); + var player = Core.PlayerManager.GetPlayer(kvp.Key); + currentMenu.HideForPlayer(player); + MenuClosed?.Invoke(this, new MenuManagerEventArgs { Player = player, Menu = currentMenu }); currentMenu = currentMenu.Parent.ParentMenu; } _ = openMenus.TryRemove(kvp.Key, out _); @@ -382,19 +379,22 @@ public void CloseAllMenus() private void CloseMenuForPlayerInternal( IPlayer player, IMenuAPI menu, bool reopenParent ) { - Console.WriteLine($"CloseMenuForPlayerInternal {player.Controller?.PlayerName} {menu.Configuration.Title}"); + if (player.IsFakeClient) + { + return; + } - if (!openMenus.TryGetValue(player, out var currentMenu) || currentMenu != menu) + if (!openMenus.TryGetValue(player.PlayerID, out var currentMenu) || currentMenu != menu) { return; } - if (onClosedCallbacks.TryRemove((player, menu), out var onClosed) && onClosed != null) + if (onClosedCallbacks.TryRemove((player.PlayerID, menu), out var onClosed) && onClosed != null) { onClosed(player, menu); } - if (openMenus.TryRemove(player, out _)) + if (openMenus.TryRemove(player.PlayerID, out _)) { menu.HideForPlayer(player); MenuClosed?.Invoke(this, new MenuManagerEventArgs { Player = player, Menu = menu }); diff --git a/managed/src/SwiftlyS2.Core/Modules/Menus/OptionsBase/ChoiceMenuOption.cs b/managed/src/SwiftlyS2.Core/Modules/Menus/OptionsBase/ChoiceMenuOption.cs index 6fc6190aa..caa2e8dc4 100644 --- a/managed/src/SwiftlyS2.Core/Modules/Menus/OptionsBase/ChoiceMenuOption.cs +++ b/managed/src/SwiftlyS2.Core/Modules/Menus/OptionsBase/ChoiceMenuOption.cs @@ -9,7 +9,7 @@ namespace SwiftlyS2.Core.Menus.OptionsBase; /// public sealed class ChoiceMenuOption : MenuOptionBase { - private readonly ConcurrentDictionary selectedIndices = new(); + private readonly ConcurrentDictionary selectedIndices = new(); private readonly List choices; private readonly int defaultIndex; @@ -75,7 +75,7 @@ public ChoiceMenuOption( public override string GetDisplayText( IPlayer player, int displayLine = 0 ) { var text = base.GetDisplayText(player, displayLine); - var index = selectedIndices.GetOrAdd(player, defaultIndex); + var index = selectedIndices.GetOrAdd(player.PlayerID, defaultIndex); var choice = choices[Math.Clamp(index, 0, choices.Count - 1)]; return $"{text}: [{choice}]"; } @@ -87,7 +87,7 @@ public override string GetDisplayText( IPlayer player, int displayLine = 0 ) /// The currently selected choice string. public string GetSelectedChoice( IPlayer player ) { - var index = selectedIndices.GetOrAdd(player, defaultIndex); + var index = selectedIndices.GetOrAdd(player.PlayerID, defaultIndex); return choices[Math.Clamp(index, 0, choices.Count - 1)]; } @@ -101,14 +101,14 @@ public void SetSelectedChoice( IPlayer player, string choice ) var index = choices.IndexOf(choice); if (index >= 0) { - _ = selectedIndices.AddOrUpdate(player, index, ( _, _ ) => index); + _ = selectedIndices.AddOrUpdate(player.PlayerID, index, ( _, _ ) => index); } } private ValueTask OnChoiceClick( object? sender, MenuOptionClickEventArgs args ) { var newIndex = selectedIndices.AddOrUpdate( - args.Player, + args.Player.PlayerID, (defaultIndex + 1) % choices.Count, ( _, current ) => (current + 1) % choices.Count ); diff --git a/managed/src/SwiftlyS2.Core/Modules/Menus/OptionsBase/InputMenuOption.cs b/managed/src/SwiftlyS2.Core/Modules/Menus/OptionsBase/InputMenuOption.cs index b5d51224f..c4d6e2fa7 100644 --- a/managed/src/SwiftlyS2.Core/Modules/Menus/OptionsBase/InputMenuOption.cs +++ b/managed/src/SwiftlyS2.Core/Modules/Menus/OptionsBase/InputMenuOption.cs @@ -10,10 +10,10 @@ namespace SwiftlyS2.Core.Menus.OptionsBase; /// public sealed class InputMenuOption : MenuOptionBase { - private readonly ConcurrentDictionary values = new(); - private readonly ConcurrentDictionary waitingForInput = new(); - private readonly ConcurrentDictionary inputStates = new(); - private readonly ConcurrentDictionary statusClearTasks = new(); + private readonly ConcurrentDictionary values = new(); + private readonly ConcurrentDictionary waitingForInput = new(); + private readonly ConcurrentDictionary inputStates = new(); + private readonly ConcurrentDictionary statusClearTasks = new(); private readonly string defaultValue; private readonly string hintMessage; private readonly Func? validator; @@ -85,13 +85,13 @@ public InputMenuOption( public override string GetDisplayText( IPlayer player, int displayLine = 0 ) { - if (inputStates.TryGetValue(player, out var state)) + if (inputStates.TryGetValue(player.PlayerID, out var state)) { return state; } var text = base.GetDisplayText(player, displayLine); - var value = values.GetOrAdd(player, defaultValue); + var value = values.GetOrAdd(player.PlayerID, defaultValue); var displayValue = string.IsNullOrEmpty(value) ? $"(empty)" : $"{value}"; return $"{text}: {displayValue}"; } @@ -103,7 +103,7 @@ public override string GetDisplayText( IPlayer player, int displayLine = 0 ) /// The current input value. public string GetValue( IPlayer player ) { - return values.GetOrAdd(player, defaultValue); + return values.GetOrAdd(player.PlayerID, defaultValue); } /// @@ -124,8 +124,8 @@ public bool SetValue( IPlayer player, string value ) return false; } - var oldValue = values.GetOrAdd(player, defaultValue); - _ = values.AddOrUpdate(player, value, ( _, _ ) => value); + var oldValue = values.GetOrAdd(player.PlayerID, defaultValue); + _ = values.AddOrUpdate(player.PlayerID, value, ( _, _ ) => value); ValueChanged?.Invoke(this, new MenuOptionValueChangedEventArgs { Player = player, @@ -144,24 +144,24 @@ private ValueTask OnInputClick( object? sender, MenuOptionClickEventArgs args ) chatHookGuid = Menu.MenuManager.Core.Command.HookClientChat(OnChatInput); } - if (statusClearTasks.TryGetValue(args.Player, out var oldCts)) + if (statusClearTasks.TryGetValue(args.Player.PlayerID, out var oldCts)) { oldCts.Cancel(); oldCts.Dispose(); - _ = statusClearTasks.TryRemove(args.Player, out _); + _ = statusClearTasks.TryRemove(args.Player.PlayerID, out _); } - if (waitingForInput.ContainsKey(args.Player)) + if (waitingForInput.ContainsKey(args.Player.PlayerID)) { - _ = waitingForInput.TryRemove(args.Player, out _); - _ = inputStates.TryRemove(args.Player, out _); + _ = waitingForInput.TryRemove(args.Player.PlayerID, out _); + _ = inputStates.TryRemove(args.Player.PlayerID, out _); return ValueTask.CompletedTask; } - _ = inputStates.AddOrUpdate(args.Player, $"Waiting (click again to cancel)", ( _, _ ) => $"Waiting (click again to cancel)"); + _ = inputStates.AddOrUpdate(args.Player.PlayerID, $"Waiting (click again to cancel)", ( _, _ ) => $"Waiting (click again to cancel)"); args.Player.SendMessage(MessageType.Chat, hintMessage); - _ = waitingForInput.AddOrUpdate(args.Player, true, ( _, _ ) => true); + _ = waitingForInput.AddOrUpdate(args.Player.PlayerID, true, ( _, _ ) => true); return ValueTask.CompletedTask; } @@ -169,35 +169,35 @@ private ValueTask OnInputClick( object? sender, MenuOptionClickEventArgs args ) private HookResult OnChatInput( int playerId, string text, bool teamonly ) { var player = Menu?.MenuManager.Core.PlayerManager.GetPlayer(playerId); - if (player == null || !waitingForInput.ContainsKey(player)) + if (player == null || !waitingForInput.ContainsKey(player.PlayerID)) { return HookResult.Continue; } var input = text.Trim(); - _ = waitingForInput.TryRemove(player, out _); + _ = waitingForInput.TryRemove(player.PlayerID, out _); var statusMessage = string.IsNullOrWhiteSpace(input) || !SetValue(player, input) ? "Invalid input" : $"Accepted"; - _ = inputStates.AddOrUpdate(player, statusMessage, ( _, _ ) => statusMessage); + _ = inputStates.AddOrUpdate(player.PlayerID, statusMessage, ( _, _ ) => statusMessage); - if (statusClearTasks.TryGetValue(player, out var oldCts)) + if (statusClearTasks.TryGetValue(player.PlayerID, out var oldCts)) { oldCts.Cancel(); oldCts.Dispose(); } var cts = new CancellationTokenSource(); - _ = statusClearTasks.AddOrUpdate(player, cts, ( _, _ ) => cts); + _ = statusClearTasks.AddOrUpdate(player.PlayerID, cts, ( _, _ ) => cts); _ = Task.Run(async () => { try { await Task.Delay(2000, cts.Token); - _ = inputStates.TryRemove(player, out _); - if (statusClearTasks.TryRemove(player, out var completedCts)) + _ = inputStates.TryRemove(player.PlayerID, out _); + if (statusClearTasks.TryRemove(player.PlayerID, out var completedCts)) { completedCts.Dispose(); } diff --git a/managed/src/SwiftlyS2.Core/Modules/Menus/OptionsBase/MenuOptionBase.cs b/managed/src/SwiftlyS2.Core/Modules/Menus/OptionsBase/MenuOptionBase.cs index 901b7b1a2..04d27cda2 100644 --- a/managed/src/SwiftlyS2.Core/Modules/Menus/OptionsBase/MenuOptionBase.cs +++ b/managed/src/SwiftlyS2.Core/Modules/Menus/OptionsBase/MenuOptionBase.cs @@ -18,9 +18,9 @@ public abstract partial class MenuOptionBase : IMenuOption, IDisposable private bool visible = true; private bool enabled = true; private readonly DynamicTextUpdater? dynamicTextUpdater; - private readonly ConcurrentDictionary playerVisible = new(); - private readonly ConcurrentDictionary playerEnabled = new(); - private readonly ConcurrentDictionary playerClickTask = new(); + private readonly ConcurrentDictionary playerVisible = new(); + private readonly ConcurrentDictionary playerEnabled = new(); + private readonly ConcurrentDictionary playerClickTask = new(); private volatile bool disposed; @@ -264,14 +264,14 @@ public MenuOptionTextStyle TextStyle { /// /// The player to check. /// True if the click task is completed; otherwise, false. - public virtual bool IsClickTaskCompleted( IPlayer player ) => !playerClickTask.TryGetValue(player, out var value) || value.IsCompleted; + public virtual bool IsClickTaskCompleted( IPlayer player ) => !playerClickTask.TryGetValue(player.PlayerID, out var value) || value.IsCompleted; /// /// Determines whether this option is visible to the specified player. /// /// The player to check visibility for. /// True if the option is visible to the player; otherwise, false. - public virtual bool GetVisible( IPlayer player ) => playerVisible.TryGetValue(player, out var value) ? value : Visible; + public virtual bool GetVisible( IPlayer player ) => playerVisible.TryGetValue(player.PlayerID, out var value) ? value : Visible; /// /// Sets the visibility of this option for a specific player. @@ -281,14 +281,14 @@ public MenuOptionTextStyle TextStyle { /// /// The per-player visibility has lower priority than the global property. /// - public virtual void SetVisible( IPlayer player, bool visible ) => playerVisible.AddOrUpdate(player, visible, ( key, value ) => visible); + public virtual void SetVisible( IPlayer player, bool visible ) => playerVisible.AddOrUpdate(player.PlayerID, visible, ( key, value ) => visible); /// /// Determines whether this option is enabled for the specified player. /// /// The player to check enabled state for. /// True if the option is enabled for the player; otherwise, false. - public virtual bool GetEnabled( IPlayer player ) => playerEnabled.TryGetValue(player, out var value) ? value : Enabled; + public virtual bool GetEnabled( IPlayer player ) => playerEnabled.TryGetValue(player.PlayerID, out var value) ? value : Enabled; /// /// Sets the enabled state of this option for a specific player. @@ -298,7 +298,7 @@ public MenuOptionTextStyle TextStyle { /// /// The per-player enabled state has lower priority than the global property. /// - public virtual void SetEnabled( IPlayer player, bool enabled ) => playerEnabled.AddOrUpdate(player, enabled, ( key, value ) => enabled); + public virtual void SetEnabled( IPlayer player, bool enabled ) => playerEnabled.AddOrUpdate(player.PlayerID, enabled, ( key, value ) => enabled); // /// // /// Gets the text to display for this option for the specified player. @@ -366,7 +366,7 @@ public virtual string GetDisplayText( IPlayer player, int displayLine = 0 ) BeforeFormat?.Invoke(this, args); - if (playerClickTask.TryGetValue(player, out var value) && !value.IsCompleted) + if (playerClickTask.TryGetValue(player.PlayerID, out var value) && !value.IsCompleted) { args.CustomText = "Waiting..."; } @@ -466,7 +466,7 @@ public virtual async ValueTask OnClickAsync( IPlayer player ) return; } - if (playerClickTask.TryGetValue(player, out var value) && !value.IsCompleted) + if (playerClickTask.TryGetValue(player.PlayerID, out var value) && !value.IsCompleted) { return; } @@ -487,12 +487,12 @@ public virtual async ValueTask OnClickAsync( IPlayer player ) try { var clickTask = Click.Invoke(this, args).AsTask(); - _ = playerClickTask.AddOrUpdate(player, clickTask, ( _, _ ) => clickTask); + _ = playerClickTask.AddOrUpdate(player.PlayerID, clickTask, ( _, _ ) => clickTask); await clickTask; } finally { - _ = playerClickTask.TryRemove(player, out _); + _ = playerClickTask.TryRemove(player.PlayerID, out _); } } diff --git a/managed/src/SwiftlyS2.Core/Modules/Menus/OptionsBase/ProgressBarMenuOption.cs b/managed/src/SwiftlyS2.Core/Modules/Menus/OptionsBase/ProgressBarMenuOption.cs index 37547bbb4..dace5d02c 100644 --- a/managed/src/SwiftlyS2.Core/Modules/Menus/OptionsBase/ProgressBarMenuOption.cs +++ b/managed/src/SwiftlyS2.Core/Modules/Menus/OptionsBase/ProgressBarMenuOption.cs @@ -8,7 +8,7 @@ namespace SwiftlyS2.Core.Menus.OptionsBase; /// public sealed class ProgressBarMenuOption : MenuOptionBase { - private readonly ConcurrentDictionary> progressProviders = new(); + private readonly ConcurrentDictionary> progressProviders = new(); private readonly Func defaultProgressProvider; private readonly bool multiLine; private readonly string filledChar; @@ -85,7 +85,7 @@ public ProgressBarMenuOption( public override string GetDisplayText( IPlayer player, int displayLine = 0 ) { - var provider = progressProviders.GetOrAdd(player, defaultProgressProvider); + var provider = progressProviders.GetOrAdd(player.PlayerID, defaultProgressProvider); var progress = Math.Clamp(provider(), 0f, 1f); var filledCount = (int)(progress * BarWidth); var emptyCount = BarWidth - filledCount; @@ -113,7 +113,7 @@ public override string GetDisplayText( IPlayer player, int displayLine = 0 ) /// Function that returns progress value (0.0 to 1.0). public void SetProgressProvider( IPlayer player, Func progressProvider ) { - _ = progressProviders.AddOrUpdate(player, progressProvider, ( _, _ ) => progressProvider); + _ = progressProviders.AddOrUpdate(player.PlayerID, progressProvider, ( _, _ ) => progressProvider); } /// @@ -123,7 +123,7 @@ public void SetProgressProvider( IPlayer player, Func progressProvider ) /// The current progress value (0.0 to 1.0). public float GetProgress( IPlayer player ) { - var provider = progressProviders.GetOrAdd(player, defaultProgressProvider); + var provider = progressProviders.GetOrAdd(player.PlayerID, defaultProgressProvider); return Math.Clamp(provider(), 0f, 1f); } } \ No newline at end of file diff --git a/managed/src/SwiftlyS2.Core/Modules/Menus/OptionsBase/SliderMenuOption.cs b/managed/src/SwiftlyS2.Core/Modules/Menus/OptionsBase/SliderMenuOption.cs index 346e3e7e3..68ad92b54 100644 --- a/managed/src/SwiftlyS2.Core/Modules/Menus/OptionsBase/SliderMenuOption.cs +++ b/managed/src/SwiftlyS2.Core/Modules/Menus/OptionsBase/SliderMenuOption.cs @@ -9,7 +9,7 @@ namespace SwiftlyS2.Core.Menus.OptionsBase; /// public sealed class SliderMenuOption : MenuOptionBase { - private readonly ConcurrentDictionary values = new(); + private readonly ConcurrentDictionary values = new(); private readonly float defaultValue; private readonly int totalBars; @@ -111,7 +111,7 @@ public SliderMenuOption( public override string GetDisplayText( IPlayer player, int displayLine = 0 ) { var text = base.GetDisplayText(player, displayLine); - var value = values.GetOrAdd(player, defaultValue); + var value = values.GetOrAdd(player.PlayerID, defaultValue); var percentage = (value - Min) / (Max - Min); var filledBars = (int)(percentage * totalBars); @@ -131,7 +131,7 @@ public override string GetDisplayText( IPlayer player, int displayLine = 0 ) /// The current slider value. public float GetValue( IPlayer player ) { - return values.GetOrAdd(player, defaultValue); + return values.GetOrAdd(player.PlayerID, defaultValue); } /// @@ -142,15 +142,15 @@ public float GetValue( IPlayer player ) public void SetValue( IPlayer player, float value ) { var clampedValue = Math.Clamp(value, Min, Max); - _ = values.AddOrUpdate(player, clampedValue, ( _, _ ) => clampedValue); + _ = values.AddOrUpdate(player.PlayerID, clampedValue, ( _, _ ) => clampedValue); } private ValueTask OnSliderClick( object? sender, MenuOptionClickEventArgs args ) { - var oldValue = values.GetOrAdd(args.Player, defaultValue); + var oldValue = values.GetOrAdd(args.Player.PlayerID, defaultValue); var newValue = Math.Clamp(oldValue + Step > Max ? Min : oldValue + Step, Min, Max); - _ = values.AddOrUpdate(args.Player, newValue, ( _, _ ) => newValue); + _ = values.AddOrUpdate(args.Player.PlayerID, newValue, ( _, _ ) => newValue); ValueChanged?.Invoke(this, new MenuOptionValueChangedEventArgs { Player = args.Player, diff --git a/managed/src/SwiftlyS2.Core/Modules/Menus/OptionsBase/ToggleMenuOption.cs b/managed/src/SwiftlyS2.Core/Modules/Menus/OptionsBase/ToggleMenuOption.cs index 773ebd651..0b6e5d46b 100644 --- a/managed/src/SwiftlyS2.Core/Modules/Menus/OptionsBase/ToggleMenuOption.cs +++ b/managed/src/SwiftlyS2.Core/Modules/Menus/OptionsBase/ToggleMenuOption.cs @@ -9,7 +9,7 @@ namespace SwiftlyS2.Core.Menus.OptionsBase; /// public sealed class ToggleMenuOption : MenuOptionBase { - private readonly ConcurrentDictionary toggled = new(); + private readonly ConcurrentDictionary toggled = new(); private readonly bool defaultToggleState; private readonly string toggleOnSymbol; private readonly string toggleOffSymbol; @@ -76,7 +76,7 @@ public ToggleMenuOption( public override string GetDisplayText( IPlayer player, int displayLine = 0 ) { var text = base.GetDisplayText(player, displayLine); - var isToggled = toggled.GetOrAdd(player, defaultToggleState); + var isToggled = toggled.GetOrAdd(player.PlayerID, defaultToggleState); return $"{text} {(isToggled ? $"{toggleOnSymbol}" : $"{toggleOffSymbol}")}"; } @@ -87,7 +87,7 @@ public override string GetDisplayText( IPlayer player, int displayLine = 0 ) /// True if toggled on, false if toggled off. Uses the configured default value for new players. public bool GetToggleState( IPlayer player ) { - return toggled.GetOrAdd(player, defaultToggleState); + return toggled.GetOrAdd(player.PlayerID, defaultToggleState); } /// @@ -98,14 +98,14 @@ public bool GetToggleState( IPlayer player ) /// True if the value was changed, false if it was already the same value. public bool SetToggleState( IPlayer player, bool value ) { - var oldValue = toggled.GetOrAdd(player, defaultToggleState); + var oldValue = toggled.GetOrAdd(player.PlayerID, defaultToggleState); if (oldValue == value) { return false; } - _ = toggled.AddOrUpdate(player, value, ( _, _ ) => value); + _ = toggled.AddOrUpdate(player.PlayerID, value, ( _, _ ) => value); ValueChanged?.Invoke(this, new MenuOptionValueChangedEventArgs { Player = player, From bcf2bb3d7a2c491e29b16fe6c713308bb3a0562e Mon Sep 17 00:00:00 2001 From: Ambr0se Date: Fri, 21 Nov 2025 15:07:20 +0800 Subject: [PATCH 3/9] chore: Add test code --- managed/src/TestPlugin/TestPlugin.cs | 42 +++++++++++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/managed/src/TestPlugin/TestPlugin.cs b/managed/src/TestPlugin/TestPlugin.cs index e2604f2bf..c7b3665d8 100644 --- a/managed/src/TestPlugin/TestPlugin.cs +++ b/managed/src/TestPlugin/TestPlugin.cs @@ -642,6 +642,7 @@ public void GetIpCommand( ICommandContext context ) public void EmitGrenadeCommand( ICommandContext _ ) { var smoke = CSmokeGrenadeProjectile.EmitGrenade(new(0, 0, 0), new(0, 0, 0), new(0, 0, 0), Team.CT, null); + smoke.Despawn(); } [Command("ss")] @@ -670,7 +671,7 @@ public void SizeCheckCommand( ICommandContext _ ) } [Command("tm")] - public void TestMenuCommand( ICommandContext context ) + public void TestMenuCommand( ICommandContext _ ) { var buyButton = new ButtonMenuOption("Purchase") { CloseAfterClick = true }; buyButton.Click += async ( sender, args ) => @@ -962,6 +963,45 @@ public void RefactoredMenuTestCommand( ICommandContext context ) // Core.Menus.OpenMenu(player, menu); // } + [Command("mru")] + public void MenuResourceUsageCommand( ICommandContext context ) + { + var menus = new List(); + + for (var i = 0; i < 30; i++) + { + var builder = Core.MenusAPI + .CreateBuilder() + .Design.SetMenuTitle($"Test Menu {i + 1}"); + + for (var j = 0; j < 5; j++) + { + var optionText = $"Menu # {i + 1} - Option # {j + 1}"; + var button = new ButtonMenuOption(optionText) { TextStyle = MenuOptionTextStyle.ScrollLeftLoop, MaxWidth = 16f }; + button.Click += ( sender, args ) => + { + args.Player.SendChat($"Clicked: {optionText}"); + return ValueTask.CompletedTask; + }; + _ = builder.AddOption(button); + } + + menus.Add(builder.Build()); + } + + var mainMenu = Core.MenusAPI + .CreateBuilder() + .Design.SetMenuTitle("Menu"); + + for (var i = 0; i < menus.Count; i++) + { + var menuIndex = i; + _ = mainMenu.AddOption(new SubmenuMenuOption($"Menu #{i + 1}", menus[menuIndex])); + } + + Core.MenusAPI.OpenMenu(mainMenu.Build()); + } + public override void Unload() { Console.WriteLine("TestPlugin unloaded"); From f31ad47169d1153a255b5e8c45d64a2f694c4ab4 Mon Sep 17 00:00:00 2001 From: Ambr0se Date: Fri, 21 Nov 2025 20:05:35 +0800 Subject: [PATCH 4/9] feat: Support pausing animations for hidden menus --- .../SwiftlyS2.Core/Modules/Menus/MenuAPI.cs | 28 +++++++++++++++++++ .../OptionsBase/Helpers/DynamicTextUpdater.cs | 25 +++++++++++++++++ .../Menus/OptionsBase/MenuOptionBase.cs | 16 +++++++++++ .../Menus/OptionsBase/SubmenuMenuOption.cs | 2 -- 4 files changed, 69 insertions(+), 2 deletions(-) diff --git a/managed/src/SwiftlyS2.Core/Modules/Menus/MenuAPI.cs b/managed/src/SwiftlyS2.Core/Modules/Menus/MenuAPI.cs index 07e8d3311..2b02e7615 100644 --- a/managed/src/SwiftlyS2.Core/Modules/Menus/MenuAPI.cs +++ b/managed/src/SwiftlyS2.Core/Modules/Menus/MenuAPI.cs @@ -119,6 +119,8 @@ public IReadOnlyList Options { // private readonly ConcurrentDictionary renderCache = new(); private readonly CancellationTokenSource renderLoopCancellationTokenSource = new(); + private readonly Lock viewerCountLock = new(); + private int viewerCount = 0; private volatile bool disposed; @@ -447,6 +449,18 @@ public void ShowForPlayer( IPlayer player ) return; } + // Increment viewer count, resume animations if first viewer + lock (viewerCountLock) + { + if (++viewerCount >= 1) + { + lock (optionsLock) + { + options.OfType().ToList().ForEach(option => option.ResumeTextAnimation()); + } + } + } + SetFreezeState(player, Configuration.FreezePlayer); if (Configuration.AutoCloseAfter > 0) @@ -480,6 +494,20 @@ public void HideForPlayer( IPlayer player ) { NativePlayer.ClearCenterMenuRender(player.PlayerID); core.Scheduler.NextTick(() => NativePlayer.ClearCenterMenuRender(player.PlayerID)); + + // Decrement viewer count, pause animations if no viewers left + lock (viewerCountLock) + { + viewerCount = Math.Max(0, viewerCount - 1); + + if (viewerCount == 0) + { + lock (optionsLock) + { + options.OfType().ToList().ForEach(option => option.PauseTextAnimation()); + } + } + } } SetFreezeState(player, false); 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 214439cd6..f2a945072 100644 --- a/managed/src/SwiftlyS2.Core/Modules/Menus/OptionsBase/Helpers/DynamicTextUpdater.cs +++ b/managed/src/SwiftlyS2.Core/Modules/Menus/OptionsBase/Helpers/DynamicTextUpdater.cs @@ -10,6 +10,7 @@ internal sealed class DynamicTextUpdater : IDisposable private readonly Func getMaxWidth; private readonly Action setDynamicText; private readonly CancellationTokenSource cancellationTokenSource; + private readonly ManualResetEventSlim resumeEvent; private volatile bool disposed; @@ -30,6 +31,8 @@ public DynamicTextUpdater( processor = new(); cancellationTokenSource = new(); + resumeEvent = new(false); // Initially paused, need manual Resume() to start + _ = Task.Run(() => UpdateLoopAsync(updateIntervalMs, pauseIntervalMs, cancellationTokenSource.Token), cancellationTokenSource.Token); } @@ -46,21 +49,43 @@ public void Dispose() } // Console.WriteLine($"{GetType().Name} has been disposed."); + resumeEvent.Set(); // Ensure any waiting thread can exit + cancellationTokenSource.Cancel(); cancellationTokenSource.Dispose(); + resumeEvent.Dispose(); processor.Dispose(); disposed = true; GC.SuppressFinalize(this); } + public void Pause() + { + if (!disposed) + { + resumeEvent.Reset(); + } + } + + public void Resume() + { + if (!disposed) + { + resumeEvent.Set(); + } + } + private async Task UpdateLoopAsync( int intervalMs, int pauseIntervalMs, CancellationToken token ) { while (!token.IsCancellationRequested && !disposed) { try { + // Wait if paused + resumeEvent.Wait(token); + await Task.Delay(intervalMs, token); var sourceText = getSourceText(); var textStyle = getTextStyle(); diff --git a/managed/src/SwiftlyS2.Core/Modules/Menus/OptionsBase/MenuOptionBase.cs b/managed/src/SwiftlyS2.Core/Modules/Menus/OptionsBase/MenuOptionBase.cs index 04d27cda2..71b1b24b8 100644 --- a/managed/src/SwiftlyS2.Core/Modules/Menus/OptionsBase/MenuOptionBase.cs +++ b/managed/src/SwiftlyS2.Core/Modules/Menus/OptionsBase/MenuOptionBase.cs @@ -89,6 +89,22 @@ public virtual void Dispose() GC.SuppressFinalize(this); } + /// + /// Pauses the dynamic text animation. + /// + public void PauseTextAnimation() + { + dynamicTextUpdater?.Pause(); + } + + /// + /// Resumes the dynamic text animation. + /// + public void ResumeTextAnimation() + { + dynamicTextUpdater?.Resume(); + } + /// /// Gets or sets the menu that this option belongs to. /// diff --git a/managed/src/SwiftlyS2.Core/Modules/Menus/OptionsBase/SubmenuMenuOption.cs b/managed/src/SwiftlyS2.Core/Modules/Menus/OptionsBase/SubmenuMenuOption.cs index 15c04e2f8..8ff833efb 100644 --- a/managed/src/SwiftlyS2.Core/Modules/Menus/OptionsBase/SubmenuMenuOption.cs +++ b/managed/src/SwiftlyS2.Core/Modules/Menus/OptionsBase/SubmenuMenuOption.cs @@ -1,6 +1,4 @@ -using System.Collections.Concurrent; using SwiftlyS2.Shared.Menus; -using SwiftlyS2.Shared.Players; namespace SwiftlyS2.Core.Menus.OptionsBase; From 0c3fd8785504ce9814bea52fda976d4a911c776e Mon Sep 17 00:00:00 2001 From: Ambr0se Date: Fri, 21 Nov 2025 21:21:33 +0800 Subject: [PATCH 5/9] fix: Switch to SemaphoreSlim to prevent thread starvation --- .../SwiftlyS2.Core/Modules/Menus/MenuAPI.cs | 20 ++++++++++--------- .../OptionsBase/Helpers/DynamicTextUpdater.cs | 20 ++++++++++--------- managed/src/TestPlugin/TestPlugin.cs | 2 +- 3 files changed, 23 insertions(+), 19 deletions(-) diff --git a/managed/src/SwiftlyS2.Core/Modules/Menus/MenuAPI.cs b/managed/src/SwiftlyS2.Core/Modules/Menus/MenuAPI.cs index 2b02e7615..bd7238290 100644 --- a/managed/src/SwiftlyS2.Core/Modules/Menus/MenuAPI.cs +++ b/managed/src/SwiftlyS2.Core/Modules/Menus/MenuAPI.cs @@ -119,8 +119,8 @@ public IReadOnlyList Options { // private readonly ConcurrentDictionary renderCache = new(); private readonly CancellationTokenSource renderLoopCancellationTokenSource = new(); - private readonly Lock viewerCountLock = new(); - private int viewerCount = 0; + private readonly Lock viewersLock = new(); + private readonly HashSet viewers = new(); private volatile bool disposed; @@ -449,10 +449,12 @@ public void ShowForPlayer( IPlayer player ) return; } - // Increment viewer count, resume animations if first viewer - lock (viewerCountLock) + // Add viewer, resume animations if first viewer + lock (viewersLock) { - if (++viewerCount >= 1) + _ = viewers.Add(player.PlayerID); + + if (viewers.Count == 1) { lock (optionsLock) { @@ -495,12 +497,12 @@ public void HideForPlayer( IPlayer player ) NativePlayer.ClearCenterMenuRender(player.PlayerID); core.Scheduler.NextTick(() => NativePlayer.ClearCenterMenuRender(player.PlayerID)); - // Decrement viewer count, pause animations if no viewers left - lock (viewerCountLock) + // Remove viewer, pause animations if no viewers left + lock (viewersLock) { - viewerCount = Math.Max(0, viewerCount - 1); + _ = viewers.Remove(player.PlayerID); - if (viewerCount == 0) + if (viewers.Count == 0) { lock (optionsLock) { 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 f2a945072..e49c4d937 100644 --- a/managed/src/SwiftlyS2.Core/Modules/Menus/OptionsBase/Helpers/DynamicTextUpdater.cs +++ b/managed/src/SwiftlyS2.Core/Modules/Menus/OptionsBase/Helpers/DynamicTextUpdater.cs @@ -1,4 +1,5 @@ using SwiftlyS2.Shared.Menus; +using System.Threading; namespace SwiftlyS2.Core.Menus.OptionsBase.Helpers; @@ -10,7 +11,7 @@ internal sealed class DynamicTextUpdater : IDisposable private readonly Func getMaxWidth; private readonly Action setDynamicText; private readonly CancellationTokenSource cancellationTokenSource; - private readonly ManualResetEventSlim resumeEvent; + private readonly SemaphoreSlim pauseSemaphore; private volatile bool disposed; @@ -31,7 +32,7 @@ public DynamicTextUpdater( processor = new(); cancellationTokenSource = new(); - resumeEvent = new(false); // Initially paused, need manual Resume() to start + pauseSemaphore = new(0); // Initially paused (0 count), need manual Resume() to start _ = Task.Run(() => UpdateLoopAsync(updateIntervalMs, pauseIntervalMs, cancellationTokenSource.Token), cancellationTokenSource.Token); } @@ -49,12 +50,12 @@ public void Dispose() } // Console.WriteLine($"{GetType().Name} has been disposed."); - resumeEvent.Set(); // Ensure any waiting thread can exit + _ = pauseSemaphore.Release(); // Ensure any waiting thread can exit cancellationTokenSource.Cancel(); cancellationTokenSource.Dispose(); - resumeEvent.Dispose(); + pauseSemaphore.Dispose(); processor.Dispose(); disposed = true; @@ -63,17 +64,17 @@ public void Dispose() public void Pause() { - if (!disposed) + if (!disposed && pauseSemaphore.CurrentCount > 0) { - resumeEvent.Reset(); + pauseSemaphore.Wait(); } } public void Resume() { - if (!disposed) + if (!disposed && pauseSemaphore.CurrentCount == 0) { - resumeEvent.Set(); + _ = pauseSemaphore.Release(); } } @@ -84,7 +85,8 @@ private async Task UpdateLoopAsync( int intervalMs, int pauseIntervalMs, Cancell try { // Wait if paused - resumeEvent.Wait(token); + await pauseSemaphore.WaitAsync(token); + _ = pauseSemaphore.Release(); await Task.Delay(intervalMs, token); var sourceText = getSourceText(); diff --git a/managed/src/TestPlugin/TestPlugin.cs b/managed/src/TestPlugin/TestPlugin.cs index c7b3665d8..34127d9a0 100644 --- a/managed/src/TestPlugin/TestPlugin.cs +++ b/managed/src/TestPlugin/TestPlugin.cs @@ -999,7 +999,7 @@ public void MenuResourceUsageCommand( ICommandContext context ) _ = mainMenu.AddOption(new SubmenuMenuOption($"Menu #{i + 1}", menus[menuIndex])); } - Core.MenusAPI.OpenMenu(mainMenu.Build()); + Core.MenusAPI.OpenMenuForPlayer(context.Sender!, mainMenu.Build()); } public override void Unload() From cb4f6bd7c4c1dc9d5583446881967957ffc9571a Mon Sep 17 00:00:00 2001 From: Ambr0se Date: Fri, 21 Nov 2025 21:21:58 +0800 Subject: [PATCH 6/9] chore: Clean up code --- .../Modules/Menus/OptionsBase/Helpers/DynamicTextUpdater.cs | 1 - 1 file changed, 1 deletion(-) 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 e49c4d937..c4055a969 100644 --- a/managed/src/SwiftlyS2.Core/Modules/Menus/OptionsBase/Helpers/DynamicTextUpdater.cs +++ b/managed/src/SwiftlyS2.Core/Modules/Menus/OptionsBase/Helpers/DynamicTextUpdater.cs @@ -1,5 +1,4 @@ using SwiftlyS2.Shared.Menus; -using System.Threading; namespace SwiftlyS2.Core.Menus.OptionsBase.Helpers; From 7a6fd968d60728addb2381e5170afcc382a29dc7 Mon Sep 17 00:00:00 2001 From: Ambr0se Date: Fri, 21 Nov 2025 21:31:32 +0800 Subject: [PATCH 7/9] chore: Ensure semaphore does not overflow --- .../OptionsBase/Helpers/DynamicTextUpdater.cs | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) 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 c4055a969..17e75dbbe 100644 --- a/managed/src/SwiftlyS2.Core/Modules/Menus/OptionsBase/Helpers/DynamicTextUpdater.cs +++ b/managed/src/SwiftlyS2.Core/Modules/Menus/OptionsBase/Helpers/DynamicTextUpdater.cs @@ -31,7 +31,7 @@ public DynamicTextUpdater( processor = new(); cancellationTokenSource = new(); - pauseSemaphore = new(0); // Initially paused (0 count), need manual Resume() to start + pauseSemaphore = new(0, 1); // Initially paused (0 count), need manual Resume() to start _ = Task.Run(() => UpdateLoopAsync(updateIntervalMs, pauseIntervalMs, cancellationTokenSource.Token), cancellationTokenSource.Token); } @@ -63,18 +63,26 @@ public void Dispose() public void Pause() { - if (!disposed && pauseSemaphore.CurrentCount > 0) + try { - pauseSemaphore.Wait(); + if (!disposed) + { + pauseSemaphore.Wait(); + } } + catch { } } public void Resume() { - if (!disposed && pauseSemaphore.CurrentCount == 0) + try { - _ = pauseSemaphore.Release(); + if (!disposed) + { + _ = pauseSemaphore.Release(); + } } + catch { } } private async Task UpdateLoopAsync( int intervalMs, int pauseIntervalMs, CancellationToken token ) From 5b283f9c1da30503fd7f237ed772b5796a879300 Mon Sep 17 00:00:00 2001 From: Ambr0se Date: Fri, 21 Nov 2025 22:54:13 +0800 Subject: [PATCH 8/9] chore: Use unified task in MenuOptionBase to avoid high CPU overhead from concurrency --- .../SwiftlyS2.Core/Modules/Menus/MenuAPI.cs | 22 +++++ .../OptionsBase/Helpers/DynamicTextUpdater.cs | 97 +++++++++---------- .../Menus/OptionsBase/MenuOptionBase.cs | 11 +++ 3 files changed, 80 insertions(+), 50 deletions(-) diff --git a/managed/src/SwiftlyS2.Core/Modules/Menus/MenuAPI.cs b/managed/src/SwiftlyS2.Core/Modules/Menus/MenuAPI.cs index bd7238290..5240ba566 100644 --- a/managed/src/SwiftlyS2.Core/Modules/Menus/MenuAPI.cs +++ b/managed/src/SwiftlyS2.Core/Modules/Menus/MenuAPI.cs @@ -255,6 +255,28 @@ private void OnRender() return; } + var now = DateTime.UtcNow; + lock (optionsLock) + { + try + { + const string category = "MenuAPI::UpdateDynamicText"; + core.Profiler.StartRecording(category); + + foreach (var option in options) + { + if (option is OptionsBase.MenuOptionBase optionBase) + { + optionBase.UpdateDynamicText(now); + } + } + + core.Profiler.StopRecording(category); + } + catch + { } + } + var playerStates = core.PlayerManager .GetAllPlayers() .Where(player => player.IsValid && !player.IsFakeClient) 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 17e75dbbe..d347da67e 100644 --- a/managed/src/SwiftlyS2.Core/Modules/Menus/OptionsBase/Helpers/DynamicTextUpdater.cs +++ b/managed/src/SwiftlyS2.Core/Modules/Menus/OptionsBase/Helpers/DynamicTextUpdater.cs @@ -9,8 +9,11 @@ internal sealed class DynamicTextUpdater : IDisposable private readonly Func getTextStyle; private readonly Func getMaxWidth; private readonly Action setDynamicText; - private readonly CancellationTokenSource cancellationTokenSource; - private readonly SemaphoreSlim pauseSemaphore; + private readonly int updateIntervalMs; + private readonly int pauseIntervalMs; + private volatile bool isPaused; + private DateTime lastUpdateTime = DateTime.MinValue; + private DateTime pauseEndTime = DateTime.MinValue; private volatile bool disposed; @@ -23,17 +26,16 @@ public DynamicTextUpdater( int pauseIntervalMs = 1000 ) { disposed = false; + isPaused = true; this.getSourceText = getSourceText; this.getTextStyle = getTextStyle; this.getMaxWidth = getMaxWidth; this.setDynamicText = setDynamicText; + this.updateIntervalMs = updateIntervalMs; + this.pauseIntervalMs = pauseIntervalMs; processor = new(); - cancellationTokenSource = new(); - pauseSemaphore = new(0, 1); // Initially paused (0 count), need manual Resume() to start - - _ = Task.Run(() => UpdateLoopAsync(updateIntervalMs, pauseIntervalMs, cancellationTokenSource.Token), cancellationTokenSource.Token); } ~DynamicTextUpdater() @@ -48,73 +50,68 @@ public void Dispose() return; } - // Console.WriteLine($"{GetType().Name} has been disposed."); - _ = pauseSemaphore.Release(); // Ensure any waiting thread can exit - - cancellationTokenSource.Cancel(); - cancellationTokenSource.Dispose(); - - pauseSemaphore.Dispose(); + disposed = true; processor.Dispose(); - disposed = true; GC.SuppressFinalize(this); } public void Pause() { - try + if (!disposed) { - if (!disposed) - { - pauseSemaphore.Wait(); - } + isPaused = true; } - catch { } } public void Resume() { - try + if (!disposed) { - if (!disposed) - { - _ = pauseSemaphore.Release(); - } + isPaused = false; + pauseEndTime = DateTime.MinValue; } - catch { } } - private async Task UpdateLoopAsync( int intervalMs, int pauseIntervalMs, CancellationToken token ) + public void TryUpdate( DateTime now ) { - while (!token.IsCancellationRequested && !disposed) + if (disposed || isPaused) { - try - { - // Wait if paused - await pauseSemaphore.WaitAsync(token); - _ = pauseSemaphore.Release(); - - await Task.Delay(intervalMs, token); - var sourceText = getSourceText(); - var textStyle = getTextStyle(); - var maxWidth = getMaxWidth(); - var (styledText, offset) = processor.ApplyHorizontalStyle(sourceText, textStyle, maxWidth); - setDynamicText(styledText); - // Console.WriteLine($"sourceText: {sourceText}, textStyle: {textStyle}, maxWidth: {maxWidth}, styledText: {styledText}, offset: {offset}"); - - if (offset == 0) - { - await Task.Delay(pauseIntervalMs, token); - } - } - catch (OperationCanceledException) + return; + } + + // Check if still in pause interval + if (now < pauseEndTime) + { + return; + } + + // Check if enough time has passed since last update + if (lastUpdateTime != DateTime.MinValue) + { + var elapsed = (now - lastUpdateTime).TotalMilliseconds; + if (elapsed < updateIntervalMs) { - break; + return; } - catch + } + + try + { + var sourceText = getSourceText(); + var textStyle = getTextStyle(); + var maxWidth = getMaxWidth(); + var (styledText, offset) = processor.ApplyHorizontalStyle(sourceText, textStyle, maxWidth); + setDynamicText(styledText); + + lastUpdateTime = now; + + // If offset is 0 (text fits completely), enter pause interval + if (offset == 0) { + pauseEndTime = now.AddMilliseconds(pauseIntervalMs); } } + catch { } } } \ No newline at end of file diff --git a/managed/src/SwiftlyS2.Core/Modules/Menus/OptionsBase/MenuOptionBase.cs b/managed/src/SwiftlyS2.Core/Modules/Menus/OptionsBase/MenuOptionBase.cs index 71b1b24b8..826d5cc90 100644 --- a/managed/src/SwiftlyS2.Core/Modules/Menus/OptionsBase/MenuOptionBase.cs +++ b/managed/src/SwiftlyS2.Core/Modules/Menus/OptionsBase/MenuOptionBase.cs @@ -527,6 +527,17 @@ public virtual async ValueTask OnClickAsync( IPlayer player ) // Hover?.Invoke(this, new MenuOptionEventArgs { Player = player, Option = this }); // } + /// + /// Updates dynamic text. + /// + /// + /// Called by MenuAPI's render loop. + /// + internal void UpdateDynamicText( DateTime now ) + { + dynamicTextUpdater?.TryUpdate(now); + } + [GeneratedRegex(@"<[/\\]*br[/\\]*>", RegexOptions.IgnoreCase)] private static partial Regex BrTagRegex(); From 54bcc9451cfea6b565ca5f1e70ab98cb3967e347 Mon Sep 17 00:00:00 2001 From: Ambr0se Date: Fri, 21 Nov 2025 23:15:40 +0800 Subject: [PATCH 9/9] chore: Clean up --- managed/src/SwiftlyS2.Core/Modules/Menus/MenuAPI.cs | 6 +++--- .../Modules/Menus/OptionsBase/Helpers/DynamicTextUpdater.cs | 3 +-- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/managed/src/SwiftlyS2.Core/Modules/Menus/MenuAPI.cs b/managed/src/SwiftlyS2.Core/Modules/Menus/MenuAPI.cs index 5240ba566..d77292b44 100644 --- a/managed/src/SwiftlyS2.Core/Modules/Menus/MenuAPI.cs +++ b/managed/src/SwiftlyS2.Core/Modules/Menus/MenuAPI.cs @@ -260,8 +260,8 @@ private void OnRender() { try { - const string category = "MenuAPI::UpdateDynamicText"; - core.Profiler.StartRecording(category); + // const string category = "MenuAPI::UpdateDynamicText"; + // core.Profiler.StartRecording(category); foreach (var option in options) { @@ -271,7 +271,7 @@ private void OnRender() } } - core.Profiler.StopRecording(category); + // core.Profiler.StopRecording(category); } catch { } 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 d347da67e..4c73a234a 100644 --- a/managed/src/SwiftlyS2.Core/Modules/Menus/OptionsBase/Helpers/DynamicTextUpdater.cs +++ b/managed/src/SwiftlyS2.Core/Modules/Menus/OptionsBase/Helpers/DynamicTextUpdater.cs @@ -89,8 +89,7 @@ public void TryUpdate( DateTime now ) // Check if enough time has passed since last update if (lastUpdateTime != DateTime.MinValue) { - var elapsed = (now - lastUpdateTime).TotalMilliseconds; - if (elapsed < updateIntervalMs) + if ((now - lastUpdateTime).TotalMilliseconds < updateIntervalMs) { return; }