diff --git a/.editorconfig b/.editorconfig index 07149cbae..63c57481f 100644 --- a/.editorconfig +++ b/.editorconfig @@ -73,3 +73,104 @@ cpp_wrap_preserve_blocks = one_liners cpp_include_cleanup_add_missing_error_tag_type = suggestion cpp_include_cleanup_remove_unused_error_tag_type = dimmed + +[*.cs] +dotnet_style_qualification_for_field = true +dotnet_style_qualification_for_property = true +dotnet_style_qualification_for_method = false +dotnet_style_qualification_for_event = true +dotnet_diagnostic.IDE0003.severity = suggestion +dotnet_style_predefined_type_for_locals_parameters_members = true +dotnet_style_predefined_type_for_member_access = true +dotnet_diagnostic.IDE0049.severity = warning +csharp_preferred_modifier_order = public,private,protected,internal,file,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,required,volatile,async +dotnet_diagnostic.IDE0036.severity = suggestion +dotnet_style_require_accessibility_modifiers = always +dotnet_diagnostic.IDE0040.severity = warning +dotnet_style_readonly_field = true +dotnet_diagnostic.IDE0044.severity = warning +csharp_prefer_static_local_function = true +dotnet_diagnostic.IDE0062.severity = warning +dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity +dotnet_diagnostic.IDE0047.severity = suggestion +dotnet_diagnostic.IDE0048.severity = suggestion +dotnet_diagnostic.IDE0010.severity = warning +dotnet_style_object_initializer = true +dotnet_diagnostic.IDE0017.severity = warning +csharp_style_inlined_variable_declaration = true +dotnet_diagnostic.IDE0018.severity = warning +dotnet_style_collection_initializer = true +dotnet_diagnostic.IDE0028.severity = warning +dotnet_style_prefer_auto_properties = true +dotnet_diagnostic.IDE0032.severity = warning +dotnet_style_explicit_tuple_names = true +dotnet_diagnostic.IDE0033.severity = warning +csharp_prefer_simple_default_expression = true +dotnet_diagnostic.IDE0034.severity = warning +dotnet_style_prefer_inferred_tuple_names = true +dotnet_style_prefer_inferred_anonymous_type_member_names = true +dotnet_diagnostic.IDE0037.severity = warning +csharp_style_prefer_local_over_anonymous_function = true +dotnet_diagnostic.IDE0039.severity = warning +csharp_style_deconstructed_variable_declaration = false +dotnet_diagnostic.IDE0042.severity = warning +dotnet_style_prefer_conditional_expression_over_assignment = true +dotnet_diagnostic.IDE0045.severity = warning +dotnet_style_prefer_conditional_expression_over_return = true +dotnet_diagnostic.IDE0046.severity = warning +dotnet_style_prefer_compound_assignment = true +dotnet_diagnostic.IDE0054.severity = warning +dotnet_diagnostic.IDE0074.severity = warning +csharp_style_prefer_index_operator = true +dotnet_diagnostic.IDE0056.severity = warning +csharp_style_prefer_range_operator = false +dotnet_diagnostic.IDE0057.severity = warning +dotnet_diagnostic.IDE0070.severity = warning +dotnet_style_prefer_simplified_interpolation = true +dotnet_diagnostic.IDE0071.severity = warning +dotnet_diagnostic.IDE0072.severity = warning +dotnet_style_prefer_simplified_boolean_expressions = true +dotnet_diagnostic.IDE0075.severity = warning +dotnet_diagnostic.IDE0082.severity = warning +csharp_style_implicit_object_creation_when_type_is_apparent = true +dotnet_diagnostic.IDE0090.severity = warning +dotnet_diagnostic.IDE0180.severity = warning +csharp_style_namespace_declarations = file_scoped +dotnet_diagnostic.IDE0160.severity = warning +dotnet_diagnostic.IDE0161.severity = warning +csharp_style_throw_expression = true +dotnet_diagnostic.IDE0016.severity = warning +dotnet_style_coalesce_expression = true +dotnet_diagnostic.IDE0029.severity = warning +dotnet_diagnostic.IDE0030.severity = warning +dotnet_style_null_propagation = true +dotnet_diagnostic.IDE0031.severity = warning +dotnet_style_prefer_is_null_check_over_reference_equality_method = true +dotnet_diagnostic.IDE0041.severity = warning +csharp_style_prefer_null_check_over_type_check = true +dotnet_diagnostic.IDE0150.severity = warning +csharp_style_conditional_delegate_call = true +dotnet_diagnostic.IDE1005.severity = warning +csharp_style_var_for_built_in_types = true +csharp_style_var_when_type_is_apparent = true +csharp_style_var_elsewhere = true +dotnet_diagnostic.IDE0007.severity = warning +dotnet_diagnostic.IDE0008.severity = warning +dotnet_diagnostic.IDE0001.severity = warning +dotnet_diagnostic.IDE0002.severity = warning +dotnet_diagnostic.IDE0004.severity = warning +dotnet_diagnostic.IDE0005.severity = warning +dotnet_diagnostic.IDE0035.severity = warning +dotnet_diagnostic.IDE0051.severity = warning +dotnet_diagnostic.IDE0052.severity = warning +csharp_style_unused_value_expression_statement_preference = discard_variable +dotnet_diagnostic.IDE0058.severity = warning +csharp_style_unused_value_assignment_preference = discard_variable +dotnet_diagnostic.IDE0059.severity = warning + +indent_style = space +csharp_indent_case_contents = true +csharp_indent_switch_labels = true +csharp_new_line_before_open_brace = types, object_collection, methods, control_blocks, lambdas +dotnet_sort_system_directives_first = true +csharp_space_between_method_declaration_parameter_list_parentheses = true \ No newline at end of file diff --git a/managed/managed.csproj b/managed/managed.csproj index ff5a11582..ee691cbee 100644 --- a/managed/managed.csproj +++ b/managed/managed.csproj @@ -49,11 +49,14 @@ + + + @@ -63,5 +66,5 @@ - + diff --git a/managed/src/SwiftlyS2.Core/Modules/Engine/EngineService.cs b/managed/src/SwiftlyS2.Core/Modules/Engine/EngineService.cs index 8f4521603..48f5f04f3 100644 --- a/managed/src/SwiftlyS2.Core/Modules/Engine/EngineService.cs +++ b/managed/src/SwiftlyS2.Core/Modules/Engine/EngineService.cs @@ -3,6 +3,7 @@ using SwiftlyS2.Shared.Services; using SwiftlyS2.Core.Extensions; using SwiftlyS2.Shared.Natives; +using SwiftlyS2.Shared.SchemaDefinitions; namespace SwiftlyS2.Core.Services; @@ -10,7 +11,7 @@ internal class EngineService : IEngineService { private readonly CommandTrackerManager _commandTrackedManager; - public EngineService(CommandTrackerManager commandTrackedManager) + public EngineService( CommandTrackerManager commandTrackedManager ) { this._commandTrackedManager = commandTrackedManager; } @@ -27,25 +28,39 @@ public EngineService(CommandTrackerManager commandTrackedManager) public int TickCount => GlobalVars.TickCount; - public void ExecuteCommand(string command) + public void ExecuteCommand( string command ) { NativeEngineHelpers.ExecuteCommand(command); } - public void ExecuteCommandWithBuffer(string command, Action bufferCallback) + public void ExecuteCommandWithBuffer( string command, Action bufferCallback ) { _commandTrackedManager.EnqueueCommand(bufferCallback); NativeEngineHelpers.ExecuteCommand($"^wb^{command}"); } - public bool IsMapValid(string map) + public bool IsMapValid( string map ) { return NativeEngineHelpers.IsMapValid(map); } - public nint? FindGameSystemByName(string name) + public nint? FindGameSystemByName( string name ) { var handle = NativeEngineHelpers.FindGameSystemByName(name); return handle == nint.Zero ? null : handle; } + + public void DispatchParticleEffect( string particleName, ParticleAttachment_t attachmentType, byte attachmentPoint, CUtlSymbolLarge attachmentName, CRecipientFilter filter, bool resetAllParticlesOnEntity = false, int splitScreenSlot = 0, CBaseEntity? entity = null ) + { + GameFunctions.DispatchParticleEffect( + particleName, + (uint)attachmentType, + entity?.Address ?? 0, + attachmentPoint, + attachmentName, + resetAllParticlesOnEntity, + splitScreenSlot, + filter + ); + } } \ No newline at end of file diff --git a/managed/src/SwiftlyS2.Core/Modules/Events/EventParams/OnEntityTouchHookEvent.cs b/managed/src/SwiftlyS2.Core/Modules/Events/EventParams/OnEntityTouchHookEvent.cs index e1676d826..b20a4eb9e 100644 --- a/managed/src/SwiftlyS2.Core/Modules/Events/EventParams/OnEntityTouchHookEvent.cs +++ b/managed/src/SwiftlyS2.Core/Modules/Events/EventParams/OnEntityTouchHookEvent.cs @@ -3,6 +3,7 @@ namespace SwiftlyS2.Core.Events; +[Obsolete("OnEntityTouchHookEvent is deprecated. Use OnEntityStartTouchEvent, OnEntityTouchEvent, or OnEntityEndTouchEvent instead.")] internal class OnEntityTouchHookEvent : IOnEntityTouchHookEvent { public required CBaseEntity Entity { get; init; } @@ -10,4 +11,25 @@ internal class OnEntityTouchHookEvent : IOnEntityTouchHookEvent public required CBaseEntity OtherEntity { get; init; } public required EntityTouchType TouchType { get; init; } +} + +internal class OnEntityStartTouchEvent : IOnEntityStartTouchEvent +{ + public required CBaseEntity Entity { get; init; } + + public required CBaseEntity OtherEntity { get; init; } +} + +internal class OnEntityTouchEvent : IOnEntityTouchEvent +{ + public required CBaseEntity Entity { get; init; } + + public required CBaseEntity OtherEntity { get; init; } +} + +internal class OnEntityEndTouchEvent : IOnEntityEndTouchEvent +{ + public required CBaseEntity Entity { get; init; } + + public required CBaseEntity OtherEntity { get; init; } } \ No newline at end of file diff --git a/managed/src/SwiftlyS2.Core/Modules/Events/EventPublisher.cs b/managed/src/SwiftlyS2.Core/Modules/Events/EventPublisher.cs index 97b08ec7a..087e50ae4 100644 --- a/managed/src/SwiftlyS2.Core/Modules/Events/EventPublisher.cs +++ b/managed/src/SwiftlyS2.Core/Modules/Events/EventPublisher.cs @@ -513,6 +513,7 @@ public static void OnPrecacheResource(nint pResourceManifest) } } + [Obsolete("InvokeOnEntityTouchHook is deprecated. Use InvokeOnEntityStartTouch, InvokeOnEntityTouch, or InvokeOnEntityEndTouch instead.")] public static void InvokeOnEntityTouchHook(OnEntityTouchHookEvent @event) { if (_subscribers.Count == 0) return; @@ -531,6 +532,60 @@ public static void InvokeOnEntityTouchHook(OnEntityTouchHookEvent @event) } } + public static void InvokeOnEntityStartTouch(OnEntityStartTouchEvent @event) + { + if (_subscribers.Count == 0) return; + try + { + foreach (var subscriber in _subscribers) + { + subscriber.InvokeOnEntityStartTouch(@event); + } + return; + } + catch (Exception e) + { + AnsiConsole.WriteException(e); + return; + } + } + + public static void InvokeOnEntityTouch(OnEntityTouchEvent @event) + { + if (_subscribers.Count == 0) return; + try + { + foreach (var subscriber in _subscribers) + { + subscriber.InvokeOnEntityTouch(@event); + } + return; + } + catch (Exception e) + { + AnsiConsole.WriteException(e); + return; + } + } + + public static void InvokeOnEntityEndTouch(OnEntityEndTouchEvent @event) + { + if (_subscribers.Count == 0) return; + try + { + foreach (var subscriber in _subscribers) + { + subscriber.InvokeOnEntityEndTouch(@event); + } + return; + } + catch (Exception e) + { + AnsiConsole.WriteException(e); + return; + } + } + public static void InvokeOnCanAcquireHook(OnItemServicesCanAcquireHookEvent @event) { if (_subscribers.Count == 0) return; diff --git a/managed/src/SwiftlyS2.Core/Modules/Events/EventSubscriber.cs b/managed/src/SwiftlyS2.Core/Modules/Events/EventSubscriber.cs index ddb97a679..de97afe09 100644 --- a/managed/src/SwiftlyS2.Core/Modules/Events/EventSubscriber.cs +++ b/managed/src/SwiftlyS2.Core/Modules/Events/EventSubscriber.cs @@ -47,6 +47,9 @@ public EventSubscriber(CoreContext id, IContextedProfilerService profiler, ILogg public event EventDelegates.OnEntityTakeDamage? OnEntityTakeDamage; public event EventDelegates.OnPrecacheResource? OnPrecacheResource; public event EventDelegates.OnEntityTouchHook? OnEntityTouchHook; + public event EventDelegates.OnEntityStartTouch? OnEntityStartTouch; + public event EventDelegates.OnEntityTouch? OnEntityTouch; + public event EventDelegates.OnEntityEndTouch? OnEntityEndTouch; public event EventDelegates.OnItemServicesCanAcquireHook? OnItemServicesCanAcquireHook; public event EventDelegates.OnWeaponServicesCanUseHook? OnWeaponServicesCanUseHook; public event EventDelegates.OnConsoleOutput? OnConsoleOutput; @@ -344,6 +347,7 @@ public void InvokeOnPrecacheResource(OnPrecacheResourceEvent @event) } } + [Obsolete("InvokeOnEntityTouchHook is deprecated. Use InvokeOnEntityStartTouch, InvokeOnEntityTouch, or InvokeOnEntityEndTouch instead.")] public void InvokeOnEntityTouchHook(OnEntityTouchHookEvent @event) { try @@ -362,6 +366,60 @@ public void InvokeOnEntityTouchHook(OnEntityTouchHookEvent @event) } } + public void InvokeOnEntityStartTouch(OnEntityStartTouchEvent @event) + { + try + { + if (OnEntityStartTouch == null) return; + _Profiler.StartRecording("Event::OnEntityStartTouch"); + OnEntityStartTouch?.Invoke(@event); + } + catch (Exception e) + { + _Logger.LogError(e, "Error invoking OnEntityStartTouch."); + } + finally + { + _Profiler.StopRecording("Event::OnEntityStartTouch"); + } + } + + public void InvokeOnEntityTouch(OnEntityTouchEvent @event) + { + try + { + if (OnEntityTouch == null) return; + _Profiler.StartRecording("Event::OnEntityTouch"); + OnEntityTouch?.Invoke(@event); + } + catch (Exception e) + { + _Logger.LogError(e, "Error invoking OnEntityTouch."); + } + finally + { + _Profiler.StopRecording("Event::OnEntityTouch"); + } + } + + public void InvokeOnEntityEndTouch(OnEntityEndTouchEvent @event) + { + try + { + if (OnEntityEndTouch == null) return; + _Profiler.StartRecording("Event::OnEntityEndTouch"); + OnEntityEndTouch?.Invoke(@event); + } + catch (Exception e) + { + _Logger.LogError(e, "Error invoking OnEntityEndTouch."); + } + finally + { + _Profiler.StopRecording("Event::OnEntityEndTouch"); + } + } + public void InvokeOnItemServicesCanAcquireHook(OnItemServicesCanAcquireHookEvent @event) { try diff --git a/managed/src/SwiftlyS2.Core/Modules/Menus/Menu.cs b/managed/src/SwiftlyS2.Core/Modules/Menus/Menu.cs index 9028ab068..c86b40401 100644 --- a/managed/src/SwiftlyS2.Core/Modules/Menus/Menu.cs +++ b/managed/src/SwiftlyS2.Core/Modules/Menus/Menu.cs @@ -1,5 +1,6 @@ using System.Collections.Concurrent; using System.Text; +using System.Text.RegularExpressions; using SwiftlyS2.Core.Menu.Options; using SwiftlyS2.Core.Natives; using SwiftlyS2.Shared; @@ -10,9 +11,9 @@ namespace SwiftlyS2.Core.Menus; -internal class Menu : IMenu +internal partial class Menu : IMenu { - public string Title { get; set; } = ""; + public string Title { get; set; } = "Menu"; public List Options { get; set; } = new(); @@ -40,10 +41,25 @@ internal class Menu : IMenu private ConcurrentDictionary RenderedText { get; set; } = new(); private ConcurrentDictionary SelectedIndex { get; set; } = new(); private List PlayersWithMenuOpen { get; set; } = new(); + private ConcurrentDictionary ScrollOffsets { get; set; } = new(); + private ConcurrentDictionary ScrollCallCounts { get; set; } = new(); + private ConcurrentDictionary ScrollPauseCounts { get; set; } = new(); internal ISwiftlyCore _Core { get; set; } public bool HasSound { get; set; } = true; public bool RenderOntick { get; set; } = false; private bool Initialized { get; set; } = false; + public MenuVerticalScrollStyle VerticalScrollStyle { get; set; } = MenuVerticalScrollStyle.CenterFixed; + public MenuHorizontalStyle? HorizontalStyle { get; set; } = null; + public bool RequiresTickRendering => RenderOntick || + HorizontalStyle?.OverflowStyle == MenuHorizontalOverflowStyle.ScrollLeftFade || + HorizontalStyle?.OverflowStyle == MenuHorizontalOverflowStyle.ScrollRightFade || + HorizontalStyle?.OverflowStyle == MenuHorizontalOverflowStyle.ScrollLeftLoop || + HorizontalStyle?.OverflowStyle == MenuHorizontalOverflowStyle.ScrollRightLoop || + Options.Any(opt => + opt.OverflowStyle?.OverflowStyle == MenuHorizontalOverflowStyle.ScrollLeftFade || + opt.OverflowStyle?.OverflowStyle == MenuHorizontalOverflowStyle.ScrollRightFade || + opt.OverflowStyle?.OverflowStyle == MenuHorizontalOverflowStyle.ScrollLeftLoop || + opt.OverflowStyle?.OverflowStyle == MenuHorizontalOverflowStyle.ScrollRightLoop); public void Close(IPlayer player) { @@ -53,11 +69,15 @@ public void Close(IPlayer player) PlayersWithMenuOpen.Remove(player); - if (Initialized && PlayersWithMenuOpen.Count == 0 && RenderOntick) + if (Initialized && PlayersWithMenuOpen.Count == 0 && RequiresTickRendering) { Initialized = false; _Core.Event.OnTick -= OnTickRender; } + + ScrollOffsets.Clear(); + ScrollCallCounts.Clear(); + ScrollPauseCounts.Clear(); } public void MoveSelection(IPlayer player, int offset) @@ -87,7 +107,7 @@ public void MoveSelection(IPlayer player, int offset) Rerender(player); } - public void Rerender(IPlayer player) + public void Rerender(IPlayer player, bool updateDisplayText = false) { BeforeRender?.Invoke(player); @@ -126,11 +146,36 @@ public void Rerender(IPlayer player) var selectedIdx = SelectedIndex[player]; var halfVisible = maxVisibleOptions / 2; - for (int offset = -halfVisible; offset <= halfVisible && offset < maxVisibleOptions - halfVisible; offset++) + var (startIndex, arrowPosition) = VerticalScrollStyle switch + { + MenuVerticalScrollStyle.WaitingCenter when selectedIdx < halfVisible + => (0, selectedIdx), // WaitingCenter: (Near top) start from 0, arrow at selected + MenuVerticalScrollStyle.WaitingCenter when selectedIdx >= totalOptions - halfVisible + => (totalOptions - maxVisibleOptions, maxVisibleOptions - (totalOptions - selectedIdx)), // WaitingCenter: (Near bottom) start from end-visible, arrow at bottom area + MenuVerticalScrollStyle.WaitingCenter + => (selectedIdx - halfVisible, halfVisible), // WaitingCenter: (Middle) start from selected-half, arrow at center + + MenuVerticalScrollStyle.LinearScroll when maxVisibleOptions == 1 + => (selectedIdx, 0), // LinearScroll: single visible, start from selected, arrow at top + + MenuVerticalScrollStyle.LinearScroll when selectedIdx < maxVisibleOptions - 1 + => (0, selectedIdx), // LinearScroll: (Near top) start from 0, arrow at selected + MenuVerticalScrollStyle.LinearScroll when selectedIdx >= totalOptions - (maxVisibleOptions - 1) + => (totalOptions - maxVisibleOptions, maxVisibleOptions - (totalOptions - selectedIdx)), // LinearScroll: (Near bottom) start from end-visible, arrow at bottom area + MenuVerticalScrollStyle.LinearScroll + => (selectedIdx - (maxVisibleOptions - 1), maxVisibleOptions - 1), // LinearScroll: (Middle) start from selected-visible+1, arrow at bottom + + _ + => (-1, halfVisible) // CenterFixed: no scroll, arrow at middle + }; + + for (int i = 0; i < maxVisibleOptions; i++) { - var actualIndex = (selectedIdx + offset + totalOptions) % totalOptions; + var (actualIndex, isSelected) = VerticalScrollStyle == MenuVerticalScrollStyle.CenterFixed + ? ((selectedIdx + i - halfVisible + totalOptions) % totalOptions, i == halfVisible) // CenterFixed: circular wrap, arrow at center + : (startIndex + i, i == arrowPosition); // WaitingCenter / LinearScroll: linear offset, arrow at position + var option = visibleOptions[actualIndex]; - var isSelected = offset == 0; var arrowSizeClass = MenuSizeHelper.GetSizeClass(option.GetTextSize()); if (isSelected) @@ -142,7 +187,10 @@ public void Rerender(IPlayer player) html.Append("\u00A0\u00A0\u00A0 "); } - html.Append(option.GetDisplayText(player)); + if (updateDisplayText) + { + html.Append(option.GetDisplayText(player)); + } html.Append("
"); } @@ -164,7 +212,10 @@ public void Rerender(IPlayer player) html.Append("\u00A0\u00A0\u00A0 "); } - html.Append(option.GetDisplayText(player)); + if (updateDisplayText) + { + html.Append(option.GetDisplayText(player)); + } html.Append("
"); } @@ -205,9 +256,9 @@ private string BuildFooter() private void OnTickRender() { - foreach (var p in PlayersWithMenuOpen) + foreach (var player in PlayersWithMenuOpen) { - Rerender(p); + Rerender(player, true); } } @@ -223,7 +274,7 @@ public void Show(IPlayer player) PlayersWithMenuOpen.Add(player); } - if (!Initialized && RenderOntick) + if (!Initialized && RequiresTickRendering) { Initialized = true; _Core.Event.OnTick += OnTickRender; @@ -240,6 +291,10 @@ public void Show(IPlayer player) Close(player); }); } + + ScrollOffsets.Clear(); + ScrollCallCounts.Clear(); + ScrollPauseCounts.Clear(); } public void UseSelection(IPlayer player) @@ -271,7 +326,7 @@ public void UseSelection(IPlayer player) } asyncButton.IsLoading = true; asyncButton.SetLoadingText("Processing..."); - Rerender(player); + Rerender(player, true); var closeAfter = asyncButton.CloseOnSelect; Task.Run(async () => { @@ -282,7 +337,7 @@ public void UseSelection(IPlayer player) finally { asyncButton.IsLoading = false; - Rerender(player); + Rerender(player, true); if (closeAfter && player.IsValid) { @@ -301,7 +356,7 @@ public void UseSelection(IPlayer player) } else { - Rerender(player); + Rerender(player, true); } break; } @@ -371,4 +426,532 @@ public void SetFreezeState(IPlayer player, bool freeze) pawn.ActualMoveType = moveType; pawn.MoveTypeUpdated(); } + + internal string ApplyHorizontalStyle(string text, MenuHorizontalStyle? overflowStyle = null) + { + var activeStyle = overflowStyle ?? HorizontalStyle; + + if (activeStyle == null || string.IsNullOrEmpty(text)) + { + return text; + } + + var plainText = StripHtmlTags(text); + if (Helper.EstimateTextWidth(plainText) <= activeStyle.Value.MaxWidth) + { + return text; + } + + return activeStyle.Value.OverflowStyle switch + { + MenuHorizontalOverflowStyle.TruncateEnd => TruncateTextEnd(text, activeStyle.Value.MaxWidth), + MenuHorizontalOverflowStyle.TruncateBothEnds => TruncateTextBothEnds(text, activeStyle.Value.MaxWidth), + MenuHorizontalOverflowStyle.ScrollLeftFade => ScrollTextWithFade(text, activeStyle.Value.MaxWidth, true, activeStyle), + MenuHorizontalOverflowStyle.ScrollRightFade => ScrollTextWithFade(text, activeStyle.Value.MaxWidth, false, activeStyle), + MenuHorizontalOverflowStyle.ScrollLeftLoop => ScrollTextWithLoop($"{text.TrimEnd()} ", activeStyle.Value.MaxWidth, true, activeStyle), + MenuHorizontalOverflowStyle.ScrollRightLoop => ScrollTextWithLoop($" {text.TrimStart()}", activeStyle.Value.MaxWidth, false, activeStyle), + _ => text + }; + } + + private string ScrollTextWithFade(string text, float maxWidth, bool scrollLeft, MenuHorizontalStyle? style = null) + { + // Prepare scroll data and validate + var (plainChars, segments, targetCharCount) = PrepareScrollData(text, maxWidth); + if (plainChars is null) + { + return text; + } + if (targetCharCount == 0) + { + return string.Empty; + } + + // Update scroll offset (allow scrolling beyond end for complete fade-out) + var offset = UpdateScrollOffset(StripHtmlTags(text), scrollLeft, plainChars.Length + 1, style); + + // Calculate visible character range + var (skipStart, skipEnd) = scrollLeft + ? (offset, Math.Max(0, plainChars.Length - offset - targetCharCount)) + : (Math.Max(0, plainChars.Length - targetCharCount - offset), offset); + + // Build output with proper HTML tag tracking + StringBuilder result = new(); + List outputTags = [], activeTags = []; + var (charIdx, started) = (0, false); + + foreach (var (content, isTag) in segments) + { + if (isTag) + { + // Track active opening and closing tags + UpdateTagState(content, activeTags); + + // Output tags within visible window + if (started) + { + result.Append(content); + ProcessOpenTag(content, outputTags); + } + } + else + { + // Process characters within scroll window + foreach (var ch in content) + { + if (charIdx >= skipStart && charIdx < plainChars.Length - skipEnd) + { + // Apply active tags at start of output + if (!started) + { + started = true; + activeTags.ForEach(tag => { result.Append(tag); ProcessOpenTag(tag, outputTags); }); + } + result.Append(ch); + } + charIdx++; + } + } + } + + CloseOpenTags(result, outputTags); + return result.ToString(); + } + + private string ScrollTextWithLoop(string text, float maxWidth, bool scrollLeft, MenuHorizontalStyle? style = null) + { + // Prepare scroll data and validate + var (plainChars, segments, targetCharCount) = PrepareScrollData(text, maxWidth); + if (plainChars is null) + { + return text; + } + if (targetCharCount == 0) + { + return string.Empty; + } + + // Update scroll offset for circular wrapping + var offset = UpdateScrollOffset(StripHtmlTags(text), scrollLeft, plainChars.Length, style); + + // Build character-to-tags mapping for circular access + Dictionary> charToActiveTags = []; + List currentActiveTags = []; + var currentCharIdx = 0; + + foreach (var (content, isTag) in segments) + { + if (isTag) + { + // Track active opening and closing tags + UpdateTagState(content, currentActiveTags); + } + else + { + // Map each character to its active tags + foreach (var ch in content) + { + charToActiveTags[currentCharIdx] = [.. currentActiveTags]; + currentCharIdx++; + } + } + } + + // Build output in circular order with dynamic tag management + StringBuilder result = new(); + List outputTags = []; + List? previousTags = null; + + for (int i = 0; i < targetCharCount; i++) + { + // Calculate circular character index + var charIndex = scrollLeft + ? (offset + i) % plainChars.Length + : (plainChars.Length - offset + i) % plainChars.Length; + var currentTags = charToActiveTags.GetValueOrDefault(charIndex, []); + + // Close tags that are no longer active + if (previousTags is not null) + { + for (int j = previousTags.Count - 1; j >= 0; j--) + { + if (!currentTags.Contains(previousTags[j])) + { + var prevTagName = previousTags[j][1..^1].Split(' ')[0]; + result.Append($""); + var idx = outputTags.FindLastIndex(t => t.Equals(prevTagName, StringComparison.OrdinalIgnoreCase)); + if (idx >= 0) + { + outputTags.RemoveAt(idx); + } + } + } + } + + // Open new tags that are now active + foreach (var tag in currentTags) + { + if (previousTags is null || !previousTags.Contains(tag)) + { + result.Append(tag); + var tagName = tag[1..^1].Split(' ')[0]; + outputTags.Add(tagName); + } + } + + result.Append(plainChars[charIndex]); + previousTags = currentTags; + } + + CloseOpenTags(result, outputTags); + return result.ToString(); + } + + private static string TruncateTextEnd(string text, float maxWidth, string suffix = "...") + { + // Reserve space for suffix + var targetWidth = maxWidth - Helper.EstimateTextWidth(suffix); + if (targetWidth <= 0) + { + return suffix; + } + + var segments = ParseHtmlSegments(text); + StringBuilder result = new(); + List openTags = []; + var (currentWidth, reachedLimit) = (0f, false); + + foreach (var (content, isTag) in segments) + { + switch (isTag, reachedLimit) + { + // Preserve HTML tags before reaching limit + case (true, false): + result.Append(content); + ProcessOpenTag(content, openTags); + break; + + // Process plain text characters until width limit + case (false, false): + foreach (var ch in content) + { + var charWidth = Helper.GetCharWidth(ch); + if (currentWidth + charWidth > targetWidth) + { + reachedLimit = true; + break; + } + result.Append(ch); + currentWidth += charWidth; + } + break; + } + } + + if (reachedLimit) + { + result.Append(suffix); + } + + CloseOpenTags(result, openTags); + return result.ToString(); + } + + private static string TruncateTextBothEnds(string text, float maxWidth) + { + if (string.IsNullOrEmpty(text)) + { + return text; + } + + // Check if text fits without truncation + var plainText = StripHtmlTags(text); + if (Helper.EstimateTextWidth(plainText) <= maxWidth) + { + return text; + } + + // Extract all plain text characters from segments + var segments = ParseHtmlSegments(text); + var plainChars = segments + .Where(s => !s.IsTag) + .SelectMany(s => s.Content) + .ToArray(); + + if (plainChars.Length == 0) + { + return text; + } + + // Calculate how many characters can fit + var targetCharCount = CalculateTargetCharCount(plainChars, maxWidth); + if (targetCharCount == 0) + { + return string.Empty; + } + + // Calculate range to keep from middle + var skipFromStart = Math.Max(0, (plainChars.Length - targetCharCount) / 2); + var skipFromEnd = plainChars.Length - skipFromStart - targetCharCount; + + StringBuilder result = new(); + List outputOpenTags = []; + List pendingOpenTags = []; + var (plainCharIndex, hasStartedOutput) = (0, false); + + foreach (var (content, isTag) in segments) + { + switch (isTag, hasStartedOutput) + { + // Process tags after output has started + case (true, true): + result.Append(content); + ProcessOpenTag(content, outputOpenTags); + break; + + // Queue opening tags before output starts + case (true, false) when !content.StartsWith(""): + pendingOpenTags.Add(content); + break; + + // Process plain text, keeping only middle portion + case (false, _): + foreach (var ch in content) + { + if (plainCharIndex >= skipFromStart && plainCharIndex < plainChars.Length - skipFromEnd) + { + // Start output and apply pending tags + if (!hasStartedOutput) + { + hasStartedOutput = true; + pendingOpenTags.ForEach(tag => + { + result.Append(tag); + ProcessOpenTag(tag, outputOpenTags); + }); + } + result.Append(ch); + } + plainCharIndex++; + } + break; + } + } + + CloseOpenTags(result, outputOpenTags); + return result.ToString(); + } +} + +internal partial class Menu +{ + [GeneratedRegex("<.*?>")] + private static partial Regex HtmlTagRegex(); + + /// + /// Removes all HTML tags from the given text. + /// + /// The text containing HTML tags. + /// The text with all HTML tags removed. + private static string StripHtmlTags(string text) + { + if (string.IsNullOrEmpty(text)) + { + return text; + } + + return HtmlTagRegex().Replace(text, string.Empty); + } + + /// + /// Parses text into segments, separating HTML tags from plain text content. + /// + /// The text to parse. + /// A list of segments where each segment is either a tag or plain text content. + private static List<(string Content, bool IsTag)> ParseHtmlSegments(string text) + { + var tagMatches = HtmlTagRegex().Matches(text); + if (tagMatches.Count == 0) + { + return [(text, false)]; + } + + List<(string Content, bool IsTag)> segments = []; + var currentIndex = 0; + + foreach (Match match in tagMatches) + { + if (match.Index > currentIndex) + { + segments.Add((text[currentIndex..match.Index], false)); + } + segments.Add((match.Value, true)); + currentIndex = match.Index + match.Length; + } + + if (currentIndex < text.Length) + { + segments.Add((text[currentIndex..], false)); + } + + return segments; + } + + /// + /// Processes an HTML tag and updates the list of currently open tags. + /// Adds opening tags to the list and removes matching closing tags. + /// + /// The HTML tag to process. + /// The list of currently open tag names. + private static void ProcessOpenTag(string tag, List openTags) + { + var tagName = tag switch + { + ['<', '/', .. var rest] => new string(rest).TrimEnd('>').Split(' ', 2)[0], + ['<', '!', ..] => null, + [.. var chars] when chars[^1] == '/' && chars[^2] == '>' => null, + ['<', .. var rest] => new string(rest).TrimEnd('>').Split(' ', 2)[0], + _ => null + }; + + if (tagName is null) + { + return; + } + + if (tag.StartsWith(" t.Equals(tagName, StringComparison.OrdinalIgnoreCase)); + if (index >= 0) openTags.RemoveAt(index); + } + else + { + openTags.Add(tagName); + } + } + + /// + /// Appends closing tags for all currently open tags in reverse order. + /// + /// The StringBuilder to append closing tags to. + /// The list of currently open tag names. + private static void CloseOpenTags(StringBuilder result, List openTags) + { + openTags.AsEnumerable().Reverse().ToList().ForEach(tag => result.Append($"")); + } + + /// + /// Calculates how many characters can fit within the specified width. + /// + /// The characters to measure. + /// The maximum width allowed. + /// The number of characters that fit within the width. + private static int CalculateTargetCharCount(ReadOnlySpan plainChars, float maxWidth) + { + var currentWidth = 0f; + var count = 0; + + foreach (var ch in plainChars) + { + var charWidth = Helper.GetCharWidth(ch); + if (currentWidth + charWidth > maxWidth) break; + currentWidth += charWidth; + count++; + } + + return count; + } + + /// + /// Updates and returns the scroll offset for the given text. + /// The offset increments based on tick count and wraps around at the specified length. + /// + /// The plain text being scrolled. + /// Whether scrolling left or right. + /// The length at which the offset wraps around. + /// Optional horizontal style settings. + /// The current scroll offset. + private int UpdateScrollOffset(string plainText, bool scrollLeft, int wrapLength, MenuHorizontalStyle? style) + { + var key = $"{plainText}_{scrollLeft}"; + ScrollOffsets.TryAdd(key, 0); + ScrollCallCounts.TryAdd(key, 0); + ScrollPauseCounts.TryAdd(key, 0); + + var ticksPerScroll = style?.TicksPerScroll ?? HorizontalStyle?.TicksPerScroll ?? 16; + var pauseTicks = style?.PauseTicks ?? HorizontalStyle?.PauseTicks ?? 0; + + // If currently pausing, decrement pause counter and maintain current offset + if (ScrollPauseCounts[key] > 0) + { + ScrollPauseCounts[key]--; + return ScrollOffsets[key]; + } + + // Increment call count and scroll when threshold is reached + if (++ScrollCallCounts[key] >= ticksPerScroll) + { + ScrollCallCounts[key] = 0; + var newOffset = (ScrollOffsets[key] + 1) % wrapLength; + + // When completing a full loop (offset returns to 0), start pause if configured + if (newOffset == 0 && pauseTicks > 0) + { + ScrollPauseCounts[key] = pauseTicks; + } + + ScrollOffsets[key] = newOffset; + } + + return ScrollOffsets[key]; + } + + /// + /// Updates the list of active tags based on the given HTML tag content. + /// Adds opening tags and removes matching closing tags. + /// + /// The HTML tag content to process. + /// The list of currently active tags. + private static void UpdateTagState(string content, List activeTags) + { + if (!content.StartsWith("")) + { + activeTags.Add(content); + } + else if (content.StartsWith(" t[1..^1].Split(' ')[0].Equals(tagName, StringComparison.OrdinalIgnoreCase)); + if (index >= 0) + { + activeTags.RemoveAt(index); + } + } + } + + /// + /// Prepares data required for text scrolling by extracting plain characters and parsing segments. + /// + /// The text to prepare for scrolling. + /// The maximum width available for display. + /// A tuple containing plain characters array, HTML segments, and target character count. + private static (char[]? PlainChars, List<(string Content, bool IsTag)> Segments, int TargetCharCount) PrepareScrollData(string text, float maxWidth) + { + var plainText = StripHtmlTags(text); + if (Helper.EstimateTextWidth(plainText) <= maxWidth) + { + return (null, [], 0); + } + + var segments = ParseHtmlSegments(text); + var plainChars = segments.Where(s => !s.IsTag).SelectMany(s => s.Content).ToArray(); + + if (plainChars.Length == 0) + { + return (null, segments, 0); + } + + var targetCharCount = CalculateTargetCharCount(plainChars, maxWidth); + return (plainChars, segments, targetCharCount); + } } \ No newline at end of file diff --git a/managed/src/SwiftlyS2.Core/Modules/Menus/MenuBuilder.cs b/managed/src/SwiftlyS2.Core/Modules/Menus/MenuBuilder.cs index 66494a99d..a27abe5c4 100644 --- a/managed/src/SwiftlyS2.Core/Modules/Menus/MenuBuilder.cs +++ b/managed/src/SwiftlyS2.Core/Modules/Menus/MenuBuilder.cs @@ -9,6 +9,9 @@ internal class MenuBuilder : IMenuBuilder { private IMenu? _menu; private IMenu? _parent; + private MenuDesign? _design; + + public IMenuDesign Design => _design ??= new MenuDesign(_menu!); public IMenuBuilder SetMenu(IMenu menu) { @@ -16,85 +19,85 @@ public IMenuBuilder SetMenu(IMenu menu) return this; } - public IMenuBuilder AddButton(string text, Action? onClick = null, IMenuTextSize size = IMenuTextSize.Medium) + public IMenuBuilder AddButton(string text, Action? onClick = null, IMenuTextSize size = IMenuTextSize.Medium, MenuHorizontalStyle? overflowStyle = null) { - _menu!.Options.Add(new ButtonMenuOption(text, onClick, size)); + _menu!.Options.Add(new ButtonMenuOption(text, onClick, size, overflowStyle)); _menu!.Options[^1].Menu = _menu; return this; } - public IMenuBuilder AddButton(string text, Action? onClick, IMenuTextSize size = IMenuTextSize.Medium) + public IMenuBuilder AddButton(string text, Action? onClick, IMenuTextSize size = IMenuTextSize.Medium, MenuHorizontalStyle? overflowStyle = null) { - _menu!.Options.Add(new ButtonMenuOption(text, onClick, size)); + _menu!.Options.Add(new ButtonMenuOption(text, onClick, size, overflowStyle)); _menu!.Options[^1].Menu = _menu; return this; } - public IMenuBuilder AddButton(string text, Action? onClick) + public IMenuBuilder AddButton(string text, Action? onClick, MenuHorizontalStyle? overflowStyle = null) { - return AddButton(text, onClick, IMenuTextSize.Medium); + return AddButton(text, onClick, IMenuTextSize.Medium, overflowStyle); } - public IMenuBuilder AddToggle(string text, bool defaultValue = false, Action? onToggle = null, IMenuTextSize size = IMenuTextSize.Medium) + public IMenuBuilder AddToggle(string text, bool defaultValue = false, Action? onToggle = null, IMenuTextSize size = IMenuTextSize.Medium, MenuHorizontalStyle? overflowStyle = null) { - _menu!.Options.Add(new ToggleMenuOption(text, defaultValue, onToggle, size)); + _menu!.Options.Add(new ToggleMenuOption(text, defaultValue, onToggle, size, overflowStyle)); _menu!.Options[^1].Menu = _menu; return this; } - public IMenuBuilder AddToggle(string text, bool defaultValue, Action? onToggle, IMenuTextSize size = IMenuTextSize.Medium) + public IMenuBuilder AddToggle(string text, bool defaultValue, Action? onToggle, IMenuTextSize size = IMenuTextSize.Medium, MenuHorizontalStyle? overflowStyle = null) { - _menu!.Options.Add(new ToggleMenuOption(text, defaultValue, onToggle, size)); + _menu!.Options.Add(new ToggleMenuOption(text, defaultValue, onToggle, size, overflowStyle)); _menu!.Options[^1].Menu = _menu; return this; } - public IMenuBuilder AddToggle(string text, bool defaultValue, Action? onToggle) + public IMenuBuilder AddToggle(string text, bool defaultValue, Action? onToggle, MenuHorizontalStyle? overflowStyle = null) { - return AddToggle(text, defaultValue, onToggle, IMenuTextSize.Medium); + return AddToggle(text, defaultValue, onToggle, IMenuTextSize.Medium, overflowStyle); } - public IMenuBuilder AddSlider(string text, float min, float max, float defaultValue, float step = 1, Action? onChange = null, IMenuTextSize size = IMenuTextSize.Medium) + public IMenuBuilder AddSlider(string text, float min, float max, float defaultValue, float step = 1, Action? onChange = null, IMenuTextSize size = IMenuTextSize.Medium, MenuHorizontalStyle? overflowStyle = null) { - _menu!.Options.Add(new SliderMenuButton(text, min, max, defaultValue, step, onChange, size)); + _menu!.Options.Add(new SliderMenuButton(text, min, max, defaultValue, step, onChange, size, overflowStyle)); _menu!.Options[^1].Menu = _menu; return this; } - public IMenuBuilder AddSlider(string text, float min, float max, float defaultValue, float step, Action? onChange, IMenuTextSize size = IMenuTextSize.Medium) + public IMenuBuilder AddSlider(string text, float min, float max, float defaultValue, float step, Action? onChange, IMenuTextSize size = IMenuTextSize.Medium, MenuHorizontalStyle? overflowStyle = null) { - _menu!.Options.Add(new SliderMenuButton(text, min, max, defaultValue, step, onChange, size)); + _menu!.Options.Add(new SliderMenuButton(text, min, max, defaultValue, step, onChange, size, overflowStyle)); _menu!.Options[^1].Menu = _menu; return this; } - public IMenuBuilder AddSlider(string text, float min, float max, float defaultValue, float step, Action? onChange) + public IMenuBuilder AddSlider(string text, float min, float max, float defaultValue, float step, Action? onChange, MenuHorizontalStyle? overflowStyle = null) { - return AddSlider(text, min, max, defaultValue, step, onChange, IMenuTextSize.Medium); + return AddSlider(text, min, max, defaultValue, step, onChange, IMenuTextSize.Medium, overflowStyle); } - public IMenuBuilder AddAsyncButton(string text, Func onClickAsync, IMenuTextSize size = IMenuTextSize.Medium) + public IMenuBuilder AddAsyncButton(string text, Func onClickAsync, IMenuTextSize size = IMenuTextSize.Medium, MenuHorizontalStyle? overflowStyle = null) { - _menu!.Options.Add(new AsyncButtonMenuOption(text, onClickAsync, size)); + _menu!.Options.Add(new AsyncButtonMenuOption(text, onClickAsync, size, overflowStyle)); _menu!.Options[^1].Menu = _menu; return this; } - public IMenuBuilder AddAsyncButton(string text, Func onClickAsync, IMenuTextSize size = IMenuTextSize.Medium) + public IMenuBuilder AddAsyncButton(string text, Func onClickAsync, IMenuTextSize size = IMenuTextSize.Medium, MenuHorizontalStyle? overflowStyle = null) { - _menu!.Options.Add(new AsyncButtonMenuOption(text, onClickAsync, size)); + _menu!.Options.Add(new AsyncButtonMenuOption(text, onClickAsync, size, overflowStyle)); _menu!.Options[^1].Menu = _menu; return this; } - public IMenuBuilder AddAsyncButton(string text, Func onClickAsync) + public IMenuBuilder AddAsyncButton(string text, Func onClickAsync, MenuHorizontalStyle? overflowStyle = null) { - return AddAsyncButton(text, onClickAsync, IMenuTextSize.Medium); + return AddAsyncButton(text, onClickAsync, IMenuTextSize.Medium, overflowStyle); } - public IMenuBuilder AddText(string text, ITextAlign alignment = ITextAlign.Left, IMenuTextSize size = IMenuTextSize.Medium) + public IMenuBuilder AddText(string text, ITextAlign alignment = ITextAlign.Left, IMenuTextSize size = IMenuTextSize.Medium, MenuHorizontalStyle? overflowStyle = null) { - _menu!.Options.Add(new TextMenuOption(text, alignment, size)); + _menu!.Options.Add(new TextMenuOption(text, alignment, size, overflowStyle)); _menu!.Options[^1].Menu = _menu; return this; } @@ -128,41 +131,45 @@ public IMenuBuilder AddSubmenu(string text, Func submenuBuilder) { return AddSubmenu(text, submenuBuilder, IMenuTextSize.Medium); } - public IMenuBuilder AddChoice(string text, string[] choices, string? defaultChoice = null, Action? onChange = null, IMenuTextSize size = IMenuTextSize.Medium) + + public IMenuBuilder AddChoice(string text, string[] choices, string? defaultChoice = null, Action? onChange = null, IMenuTextSize size = IMenuTextSize.Medium, MenuHorizontalStyle? overflowStyle = null) { - _menu!.Options.Add(new ChoiceMenuOption(text, choices, defaultChoice, onChange, size)); + _menu!.Options.Add(new ChoiceMenuOption(text, choices, defaultChoice, onChange, size, overflowStyle)); _menu!.Options[^1].Menu = _menu; return this; } - public IMenuBuilder AddChoice(string text, string[] choices, string? defaultChoice, Action? onChange, IMenuTextSize size = IMenuTextSize.Medium) + public IMenuBuilder AddChoice(string text, string[] choices, string? defaultChoice, Action? onChange, IMenuTextSize size = IMenuTextSize.Medium, MenuHorizontalStyle? overflowStyle = null) { - _menu!.Options.Add(new ChoiceMenuOption(text, choices, defaultChoice, onChange, size)); + _menu!.Options.Add(new ChoiceMenuOption(text, choices, defaultChoice, onChange, size, overflowStyle)); _menu!.Options[^1].Menu = _menu; return this; } - public IMenuBuilder AddChoice(string text, string[] choices, string? defaultChoice, Action? onChange) - { - return AddChoice(text, choices, defaultChoice, onChange, IMenuTextSize.Medium); - } + // [Obsolete("This overload causes ambiguity. Use AddChoice(text, choices, defaultChoice, onChange, IMenuTextSize.Medium, overflowStyle) instead.")] + // public IMenuBuilder AddChoice(string text, string[] choices, string? defaultChoice, Action? onChange, MenuHorizontalStyle? overflowStyle = null) + // { + // return AddChoice(text, choices, defaultChoice, onChange, IMenuTextSize.Medium, overflowStyle); + // } + public IMenuBuilder AddSeparator() { _menu!.Options.Add(new SeparatorMenuOption()); _menu!.Options[^1].Menu = _menu; return this; } - public IMenuBuilder AddProgressBar(string text, Func progressProvider, int barWidth = 20, IMenuTextSize size = IMenuTextSize.Medium) + + public IMenuBuilder AddProgressBar(string text, Func progressProvider, int barWidth = 20, IMenuTextSize size = IMenuTextSize.Medium, MenuHorizontalStyle? overflowStyle = null) { - _menu!.Options.Add(new ProgressBarMenuOption(text, progressProvider, barWidth, size)); + _menu!.Options.Add(new ProgressBarMenuOption(text, progressProvider, barWidth, size, overflowStyle)); _menu!.Options[^1].Menu = _menu; _menu!.RenderOntick = true; return this; } - public IMenuBuilder AddProgressBar(string text, Func progressProvider, int barWidth) + public IMenuBuilder AddProgressBar(string text, Func progressProvider, int barWidth, MenuHorizontalStyle? overflowStyle = null) { - return AddProgressBar(text, progressProvider, barWidth, IMenuTextSize.Medium); + return AddProgressBar(text, progressProvider, barWidth, IMenuTextSize.Medium, overflowStyle); } public IMenuBuilder WithParent(IMenu parent) { @@ -286,28 +293,35 @@ public IMenuBuilder OverrideButtons(Action configureOverri return this; } + [Obsolete("Use Design.OverrideSelectButton instead")] public IMenuBuilder OverrideSelectButton(params string[] buttonNames) { - _menu!.ButtonOverrides.Select = MenuButtonOverrides.ParseButtons(buttonNames); + _menu!.ButtonOverrides!.Select = MenuButtonOverrides.ParseButtons(buttonNames); return this; } + [Obsolete("Use Design.OverrideMoveButton instead")] public IMenuBuilder OverrideMoveButton(params string[] buttonNames) { - _menu!.ButtonOverrides.Move = MenuButtonOverrides.ParseButtons(buttonNames); + _menu!.ButtonOverrides!.Move = MenuButtonOverrides.ParseButtons(buttonNames); return this; } - + [Obsolete("Use Design.OverrideExitButton instead")] public IMenuBuilder OverrideExitButton(params string[] buttonNames) { - _menu!.ButtonOverrides.Exit = MenuButtonOverrides.ParseButtons(buttonNames); + _menu!.ButtonOverrides!.Exit = MenuButtonOverrides.ParseButtons(buttonNames); return this; } + [Obsolete("Use Design.MaxVisibleItems instead")] public IMenuBuilder MaxVisibleItems(int count) { - _menu!.MaxVisibleOptions = Math.Max(1, count); + if (count < 1 || count > 5) + { + Spectre.Console.AnsiConsole.WriteException(new ArgumentOutOfRangeException(nameof(count), $"MaxVisibleItems: value {count} is out of range [1, 5].")); + } + _menu!.MaxVisibleOptions = Math.Clamp(count, 1, 5); return this; } @@ -329,6 +343,7 @@ public IMenuBuilder HasSound(bool hasSound) return this; } + [Obsolete("Use Design.SetColor instead")] public IMenuBuilder SetColor(Color color) { _menu!.RenderColor = color; diff --git a/managed/src/SwiftlyS2.Core/Modules/Menus/MenuDesign.cs b/managed/src/SwiftlyS2.Core/Modules/Menus/MenuDesign.cs new file mode 100644 index 000000000..4c5aafc9e --- /dev/null +++ b/managed/src/SwiftlyS2.Core/Modules/Menus/MenuDesign.cs @@ -0,0 +1,60 @@ +using SwiftlyS2.Shared.Menus; +using SwiftlyS2.Shared.Natives; + +namespace SwiftlyS2.Core.Menus; + +internal sealed class MenuDesign : IMenuDesign +{ + private readonly IMenu _menu; + + public MenuDesign(IMenu menu) + { + _menu = menu; + } + + public IMenuDesign OverrideSelectButton(params string[] buttonNames) + { + _menu.ButtonOverrides!.Select = MenuButtonOverrides.ParseButtons(buttonNames); + return this; + } + + public IMenuDesign OverrideMoveButton(params string[] buttonNames) + { + _menu.ButtonOverrides!.Move = MenuButtonOverrides.ParseButtons(buttonNames); + return this; + } + + public IMenuDesign OverrideExitButton(params string[] buttonNames) + { + _menu.ButtonOverrides!.Exit = MenuButtonOverrides.ParseButtons(buttonNames); + return this; + } + + public IMenuDesign MaxVisibleItems(int count) + { + if (count < 1 || count > 5) + { + Spectre.Console.AnsiConsole.WriteException(new ArgumentOutOfRangeException(nameof(count), $"MaxVisibleItems: value {count} is out of range [1, 5].")); + } + _menu.MaxVisibleOptions = Math.Clamp(count, 1, 5); + return this; + } + + public IMenuDesign SetColor(Color color) + { + _menu.RenderColor = color; + return this; + } + + public IMenuDesign SetVerticalScrollStyle(MenuVerticalScrollStyle style) + { + _menu.VerticalScrollStyle = style; + return this; + } + + public IMenuDesign SetGlobalHorizontalStyle(MenuHorizontalStyle style) + { + _menu.HorizontalStyle = style; + return this; + } +} \ No newline at end of file diff --git a/managed/src/SwiftlyS2.Core/Modules/Menus/MenuManager.cs b/managed/src/SwiftlyS2.Core/Modules/Menus/MenuManager.cs index 4237b3693..f352a3320 100644 --- a/managed/src/SwiftlyS2.Core/Modules/Menus/MenuManager.cs +++ b/managed/src/SwiftlyS2.Core/Modules/Menus/MenuManager.cs @@ -79,18 +79,17 @@ public MenuManager(ISwiftlyCore core) _exitSound.Volume = Settings.SoundExitVolume; _Core.Event.OnClientKeyStateChanged += KeyStateChange; + _Core.Event.OnClientDisconnected += OnClientDisconnected; + _Core.Event.OnMapUnload += OnMapUnload; } ~MenuManager() { - foreach (var kvp in OpenMenus) - { - var player = kvp.Key; - var menu = kvp.Value; - menu.Close(player); - } + CloseAllMenus(); _Core.Event.OnClientKeyStateChanged -= KeyStateChange; + _Core.Event.OnClientDisconnected -= OnClientDisconnected; + _Core.Event.OnMapUnload -= OnMapUnload; } void KeyStateChange(IOnClientKeyStateChangedEvent @event) @@ -202,6 +201,47 @@ void KeyStateChange(IOnClientKeyStateChangedEvent @event) } } + public void OnClientDisconnected(IOnClientDisconnectedEvent @event) + { + var player = _Core.PlayerManager.GetPlayer(@event.PlayerId); + if (player == null) + { + return; + } + + if (OpenMenus.TryRemove(player, out var menu)) + { + var currentMenu = menu; + while (currentMenu != null) + { + currentMenu.Close(player); + OnMenuClosed?.Invoke(player, currentMenu); + currentMenu = currentMenu.Parent; + } + } + } + + public void OnMapUnload(IOnMapUnloadEvent _) + { + CloseAllMenus(); + } + + public void CloseAllMenus() + { + foreach (var kvp in OpenMenus) + { + var player = kvp.Key; + var currentMenu = kvp.Value; + while (currentMenu != null) + { + currentMenu.Close(player); + OnMenuClosed?.Invoke(player, currentMenu); + currentMenu = currentMenu.Parent; + } + } + OpenMenus.Clear(); + } + public void CloseMenu(IMenu menu) { foreach (var kvp in OpenMenus) diff --git a/managed/src/SwiftlyS2.Core/Modules/Menus/Options/AsyncButtonMenuOption.cs b/managed/src/SwiftlyS2.Core/Modules/Menus/Options/AsyncButtonMenuOption.cs index 0400cc9e2..626169c69 100644 --- a/managed/src/SwiftlyS2.Core/Modules/Menus/Options/AsyncButtonMenuOption.cs +++ b/managed/src/SwiftlyS2.Core/Modules/Menus/Options/AsyncButtonMenuOption.cs @@ -1,4 +1,4 @@ -using SwiftlyS2.Core.Menus; +using SwiftlyS2.Core.Menus; using SwiftlyS2.Shared.Menus; using SwiftlyS2.Shared.Players; @@ -16,6 +16,7 @@ internal class AsyncButtonMenuOption : IOption public IMenuTextSize Size { get; set; } public bool CloseOnSelect { get; set; } public IMenu? Menu { get; set; } + public MenuHorizontalStyle? OverflowStyle { get; init; } public bool Visible => true; public bool Enabled => true; @@ -23,18 +24,20 @@ internal class AsyncButtonMenuOption : IOption private string? _loadingText; - public AsyncButtonMenuOption(string text, Func? onClickAsync = null, IMenuTextSize size = IMenuTextSize.Medium) + public AsyncButtonMenuOption(string text, Func? onClickAsync = null, IMenuTextSize size = IMenuTextSize.Medium, MenuHorizontalStyle? overflowStyle = null) { Text = text; OnClickAsync = onClickAsync; Size = size; + OverflowStyle = overflowStyle; } - public AsyncButtonMenuOption(string text, Func? onClickAsync, IMenuTextSize size = IMenuTextSize.Medium) + public AsyncButtonMenuOption(string text, Func? onClickAsync, IMenuTextSize size = IMenuTextSize.Medium, MenuHorizontalStyle? overflowStyle = null) { Text = text; OnClickAsyncWithOption = onClickAsync; Size = size; + OverflowStyle = overflowStyle; } public bool ShouldShow(IPlayer player) @@ -56,12 +59,13 @@ public string GetDisplayText(IPlayer player) return $"{_loadingText ?? "Loading..."}"; } + var text = (Menu as Menus.Menu)?.ApplyHorizontalStyle(Text, OverflowStyle) ?? Text; if (!CanInteract(player)) { - return $"{Text}"; + return $"{text}"; } - return $"{Text}"; + return $"{text}"; } public IMenuTextSize GetTextSize() diff --git a/managed/src/SwiftlyS2.Core/Modules/Menus/Options/ButtonMenuOption.cs b/managed/src/SwiftlyS2.Core/Modules/Menus/Options/ButtonMenuOption.cs index d2a03abbf..86afbe890 100644 --- a/managed/src/SwiftlyS2.Core/Modules/Menus/Options/ButtonMenuOption.cs +++ b/managed/src/SwiftlyS2.Core/Modules/Menus/Options/ButtonMenuOption.cs @@ -1,4 +1,4 @@ -using SwiftlyS2.Core.Menus; +using SwiftlyS2.Core.Menus; using SwiftlyS2.Shared.Menus; using SwiftlyS2.Shared.Players; @@ -16,42 +16,50 @@ internal class ButtonMenuOption : IOption public IMenuTextSize Size { get; set; } public bool CloseOnSelect { get; set; } public IMenu? Menu { get; set; } + public MenuHorizontalStyle? OverflowStyle { get; init; } public bool Visible => true; public bool Enabled => true; - public ButtonMenuOption(string text, Action? onClick = null, IMenuTextSize size = IMenuTextSize.Medium) + public ButtonMenuOption(string text, Action? onClick = null, IMenuTextSize size = IMenuTextSize.Medium, MenuHorizontalStyle? overflowStyle = null) { Text = text; OnClick = onClick; Size = size; + OverflowStyle = overflowStyle; } - public ButtonMenuOption(string text, Action? onClick, IMenuTextSize size = IMenuTextSize.Medium) + public ButtonMenuOption(string text, Action? onClick, IMenuTextSize size = IMenuTextSize.Medium, MenuHorizontalStyle? overflowStyle = null) { Text = text; OnClickWithOption = onClick; Size = size; + OverflowStyle = overflowStyle; } + public bool ShouldShow(IPlayer player) { return VisibilityCheck?.Invoke(player) ?? true; } + public bool CanInteract(IPlayer player) { return EnabledCheck?.Invoke(player) ?? true; } + public string GetDisplayText(IPlayer player) { var sizeClass = MenuSizeHelper.GetSizeClass(Size); + var text = (Menu as Menus.Menu)?.ApplyHorizontalStyle(Text, OverflowStyle) ?? Text; if (!CanInteract(player)) { - return $"{Text}"; + return $"{text}"; } - return $"{Text}"; + return $"{text}"; } + public IMenuTextSize GetTextSize() { return Size; diff --git a/managed/src/SwiftlyS2.Core/Modules/Menus/Options/ChoiceMenuButton.cs b/managed/src/SwiftlyS2.Core/Modules/Menus/Options/ChoiceMenuButton.cs index da0fb00a1..798fd42e5 100644 --- a/managed/src/SwiftlyS2.Core/Modules/Menus/Options/ChoiceMenuButton.cs +++ b/managed/src/SwiftlyS2.Core/Modules/Menus/Options/ChoiceMenuButton.cs @@ -1,4 +1,4 @@ -using SwiftlyS2.Core.Menus; +using SwiftlyS2.Core.Menus; using SwiftlyS2.Shared.Menus; using SwiftlyS2.Shared.Players; using SwiftlyS2.Shared.SchemaDefinitions; @@ -16,13 +16,14 @@ internal class ChoiceMenuOption : IOption public Func? EnabledCheck { get; set; } public IMenuTextSize Size { get; set; } public IMenu? Menu { get; set; } + public MenuHorizontalStyle? OverflowStyle { get; init; } public bool Visible => true; public bool Enabled => true; public string SelectedChoice => Choices.Count > 0 ? Choices[SelectedIndex] : ""; - public ChoiceMenuOption(string text, IEnumerable choices, string? defaultChoice = null, Action? onChange = null, IMenuTextSize size = IMenuTextSize.Medium) + public ChoiceMenuOption(string text, IEnumerable choices, string? defaultChoice = null, Action? onChange = null, IMenuTextSize size = IMenuTextSize.Medium, MenuHorizontalStyle? overflowStyle = null) { Text = text; Choices = [.. choices]; @@ -36,9 +37,10 @@ public ChoiceMenuOption(string text, IEnumerable choices, string? defaul } OnChange = onChange; + OverflowStyle = overflowStyle; } - public ChoiceMenuOption(string text, IEnumerable choices, string? defaultChoice, Action? onChange, IMenuTextSize size = IMenuTextSize.Medium) + public ChoiceMenuOption(string text, IEnumerable choices, string? defaultChoice, Action? onChange, IMenuTextSize size = IMenuTextSize.Medium, MenuHorizontalStyle? overflowStyle = null) { Text = text; Choices = [.. choices]; @@ -52,6 +54,7 @@ public ChoiceMenuOption(string text, IEnumerable choices, string? defaul } OnChangeWithOption = onChange; + OverflowStyle = overflowStyle; } public bool ShouldShow(IPlayer player) @@ -70,11 +73,12 @@ public string GetDisplayText(IPlayer player) var choice = $"[{SelectedChoice}]"; + var text = (Menu as Menus.Menu)?.ApplyHorizontalStyle(Text, OverflowStyle) ?? Text; if (!CanInteract(player)) { - return $"{Text}: {choice}"; + return $"{text}: {choice}"; } - return $"{Text}: {choice}"; + return $"{text}: {choice}"; } public IMenuTextSize GetTextSize() diff --git a/managed/src/SwiftlyS2.Core/Modules/Menus/Options/DynamicMenuOption.cs b/managed/src/SwiftlyS2.Core/Modules/Menus/Options/DynamicMenuOption.cs index 737b5de8e..ca1d401b1 100644 --- a/managed/src/SwiftlyS2.Core/Modules/Menus/Options/DynamicMenuOption.cs +++ b/managed/src/SwiftlyS2.Core/Modules/Menus/Options/DynamicMenuOption.cs @@ -1,4 +1,4 @@ -using SwiftlyS2.Core.Menus; +using SwiftlyS2.Core.Menus; using SwiftlyS2.Shared.Menus; using SwiftlyS2.Shared.Players; using SwiftlyS2.Shared.SchemaDefinitions; @@ -19,6 +19,7 @@ internal class DynamicMenuOption : IOption private IMenuTextSize _size; private bool _closeOnSelect; public IMenu? Menu { get; set; } + public MenuHorizontalStyle? OverflowStyle { get; init; } public string Text { @@ -68,7 +69,7 @@ public string GetDisplayText(IPlayer player) if (oldText != _cachedText && Menu != null) { - Menu.Rerender(player); + Menu.Rerender(player, true); } } diff --git a/managed/src/SwiftlyS2.Core/Modules/Menus/Options/ProgressBarMenuButton.cs b/managed/src/SwiftlyS2.Core/Modules/Menus/Options/ProgressBarMenuButton.cs index 3bfa16a25..7ca667407 100644 --- a/managed/src/SwiftlyS2.Core/Modules/Menus/Options/ProgressBarMenuButton.cs +++ b/managed/src/SwiftlyS2.Core/Modules/Menus/Options/ProgressBarMenuButton.cs @@ -1,10 +1,10 @@ -using SwiftlyS2.Shared.Players; +using SwiftlyS2.Shared.Players; using SwiftlyS2.Shared.Menus; using SwiftlyS2.Core.Menus; namespace SwiftlyS2.Core.Menu.Options; -internal class ProgressBarMenuOption(string text, Func progressProvider, int barWidth = 20, IMenuTextSize size = IMenuTextSize.Medium) : IOption +internal class ProgressBarMenuOption(string text, Func progressProvider, int barWidth = 20, IMenuTextSize size = IMenuTextSize.Medium, MenuHorizontalStyle? overflowStyle = null) : IOption { public string Text { get; set; } = text; public Func ProgressProvider { get; set; } = progressProvider; @@ -14,6 +14,7 @@ internal class ProgressBarMenuOption(string text, Func progressProvider, public bool ShowPercentage { get; set; } = true; public IMenuTextSize Size { get; set; } = size; public IMenu? Menu { get; set; } + public MenuHorizontalStyle? OverflowStyle { get; init; } = overflowStyle; public bool Visible => true; public bool Enabled => false; @@ -36,7 +37,7 @@ public string GetDisplayText(IPlayer player) bar += $"{EmptyChar}"; var percentage = ShowPercentage ? $" {(int)(progress * 100)}%" : ""; - return $"{Text}: {bar}{percentage}"; + return $"{((Menu as Menus.Menu)?.ApplyHorizontalStyle(Text, OverflowStyle) ?? Text)}: {bar}{percentage}"; } public IMenuTextSize GetTextSize() diff --git a/managed/src/SwiftlyS2.Core/Modules/Menus/Options/SeparatorMenuButton.cs b/managed/src/SwiftlyS2.Core/Modules/Menus/Options/SeparatorMenuButton.cs index 87a3632cd..d0936b2fe 100644 --- a/managed/src/SwiftlyS2.Core/Modules/Menus/Options/SeparatorMenuButton.cs +++ b/managed/src/SwiftlyS2.Core/Modules/Menus/Options/SeparatorMenuButton.cs @@ -1,4 +1,4 @@ -using SwiftlyS2.Shared.Players; +using SwiftlyS2.Shared.Players; using SwiftlyS2.Shared.Menus; namespace SwiftlyS2.Core.Menu.Options; @@ -10,6 +10,7 @@ internal class SeparatorMenuOption : IOption public bool Enabled => false; public IMenu? Menu { get; set; } + public MenuHorizontalStyle? OverflowStyle { get; init; } public SeparatorMenuOption() { diff --git a/managed/src/SwiftlyS2.Core/Modules/Menus/Options/SliderMenuButton.cs b/managed/src/SwiftlyS2.Core/Modules/Menus/Options/SliderMenuButton.cs index 8952c2680..d5dbeb7d8 100644 --- a/managed/src/SwiftlyS2.Core/Modules/Menus/Options/SliderMenuButton.cs +++ b/managed/src/SwiftlyS2.Core/Modules/Menus/Options/SliderMenuButton.cs @@ -1,4 +1,4 @@ -using SwiftlyS2.Core.Menus; +using SwiftlyS2.Core.Menus; using SwiftlyS2.Shared.Menus; using SwiftlyS2.Shared.Players; using SwiftlyS2.Shared.SchemaDefinitions; @@ -18,11 +18,12 @@ internal class SliderMenuButton : IOption public Func? EnabledCheck { get; set; } public IMenuTextSize Size { get; set; } public IMenu? Menu { get; set; } + public MenuHorizontalStyle? OverflowStyle { get; init; } public bool Visible => true; public bool Enabled => true; - public SliderMenuButton(string text, float min = 0, float max = 10, float defaultValue = 5, float step = 1, Action? onChange = null, IMenuTextSize size = IMenuTextSize.Medium) + public SliderMenuButton(string text, float min = 0, float max = 10, float defaultValue = 5, float step = 1, Action? onChange = null, IMenuTextSize size = IMenuTextSize.Medium, MenuHorizontalStyle? overflowStyle = null) { Text = text; Min = min; @@ -31,9 +32,10 @@ public SliderMenuButton(string text, float min = 0, float max = 10, float defaul Step = step; OnChange = onChange; Size = size; + OverflowStyle = overflowStyle; } - public SliderMenuButton(string text, float min, float max, float defaultValue, float step, Action? onChange, IMenuTextSize size = IMenuTextSize.Medium) + public SliderMenuButton(string text, float min, float max, float defaultValue, float step, Action? onChange, IMenuTextSize size = IMenuTextSize.Medium, MenuHorizontalStyle? overflowStyle = null) { Text = text; Min = min; @@ -42,6 +44,7 @@ public SliderMenuButton(string text, float min, float max, float defaultValue, f Step = step; OnChangeWithOption = onChange; Size = size; + OverflowStyle = overflowStyle; } public bool ShouldShow(IPlayer player) @@ -72,12 +75,13 @@ public string GetDisplayText(IPlayer player) } slider += $") {Value:F1}"; + var text = (Menu as Menus.Menu)?.ApplyHorizontalStyle(Text, OverflowStyle) ?? Text; if (!CanInteract(player)) { - return $"{Text}: {slider}"; + return $"{text}: {slider}"; } - return $"{Text}: {slider}"; + return $"{text}: {slider}"; } public IMenuTextSize GetTextSize() @@ -87,7 +91,7 @@ public IMenuTextSize GetTextSize() private static float Wrap(float value, float min, float max) { - float range = max - min + 1; + float range = max - min; return ((value - min) % range + range) % range + min; } diff --git a/managed/src/SwiftlyS2.Core/Modules/Menus/Options/SubmenuMenuOption.cs b/managed/src/SwiftlyS2.Core/Modules/Menus/Options/SubmenuMenuOption.cs index 9d337d034..6321bf997 100644 --- a/managed/src/SwiftlyS2.Core/Modules/Menus/Options/SubmenuMenuOption.cs +++ b/managed/src/SwiftlyS2.Core/Modules/Menus/Options/SubmenuMenuOption.cs @@ -1,4 +1,4 @@ -using SwiftlyS2.Core.Menus; +using SwiftlyS2.Core.Menus; using SwiftlyS2.Shared.Menus; using SwiftlyS2.Shared.Players; @@ -13,6 +13,7 @@ internal class SubmenuMenuOption : IOption public Func? EnabledCheck { get; set; } public IMenuTextSize Size { get; set; } public IMenu? Menu { get; set; } + public MenuHorizontalStyle? OverflowStyle { get; init; } public bool Visible => true; public bool Enabled => true; diff --git a/managed/src/SwiftlyS2.Core/Modules/Menus/Options/TextMenuOption.cs b/managed/src/SwiftlyS2.Core/Modules/Menus/Options/TextMenuOption.cs index bc7e0cf24..565f39cd1 100644 --- a/managed/src/SwiftlyS2.Core/Modules/Menus/Options/TextMenuOption.cs +++ b/managed/src/SwiftlyS2.Core/Modules/Menus/Options/TextMenuOption.cs @@ -1,4 +1,4 @@ -using SwiftlyS2.Core.Menus; +using SwiftlyS2.Core.Menus; using SwiftlyS2.Shared.Menus; using SwiftlyS2.Shared.Players; @@ -12,23 +12,26 @@ internal class TextMenuOption : IOption public Func? VisibilityCheck { get; set; } public Func? DynamicText { get; set; } public IMenu? Menu { get; set; } + public MenuHorizontalStyle? OverflowStyle { get; init; } public bool Visible => true; public bool Enabled => false; - public TextMenuOption(string text, ITextAlign alignment = ITextAlign.Left, IMenuTextSize size = IMenuTextSize.Medium) + public TextMenuOption(string text, ITextAlign alignment = ITextAlign.Left, IMenuTextSize size = IMenuTextSize.Medium, MenuHorizontalStyle? overflowStyle = null) { Text = text; Alignment = alignment; Size = size; + OverflowStyle = overflowStyle; } - public TextMenuOption(Func dynamicText, ITextAlign alignment = ITextAlign.Left, IMenuTextSize size = IMenuTextSize.Medium) + public TextMenuOption(Func dynamicText, ITextAlign alignment = ITextAlign.Left, IMenuTextSize size = IMenuTextSize.Medium, MenuHorizontalStyle? overflowStyle = null) { - Text = ""; + Text = string.Empty; DynamicText = dynamicText; Alignment = alignment; Size = size; + OverflowStyle = overflowStyle; } public bool ShouldShow(IPlayer player) @@ -45,6 +48,8 @@ public string GetDisplayText(IPlayer player) { var text = DynamicText?.Invoke() ?? Text; + text = (Menu as Menus.Menu)?.ApplyHorizontalStyle(text, OverflowStyle) ?? text; + var sizeClass = MenuSizeHelper.GetSizeClass(Size); text = $"{text}"; diff --git a/managed/src/SwiftlyS2.Core/Modules/Menus/Options/ToggleMenuOption.cs b/managed/src/SwiftlyS2.Core/Modules/Menus/Options/ToggleMenuOption.cs index b0ae040d0..d488af446 100644 --- a/managed/src/SwiftlyS2.Core/Modules/Menus/Options/ToggleMenuOption.cs +++ b/managed/src/SwiftlyS2.Core/Modules/Menus/Options/ToggleMenuOption.cs @@ -1,4 +1,4 @@ -using SwiftlyS2.Core.Menus; +using SwiftlyS2.Core.Menus; using SwiftlyS2.Shared.Menus; using SwiftlyS2.Shared.Players; @@ -17,24 +17,27 @@ internal class ToggleMenuOption : IOption public IMenuTextSize Size { get; set; } public bool CloseOnSelect { get; set; } public IMenu? Menu { get; set; } + public MenuHorizontalStyle? OverflowStyle { get; init; } public bool Visible => true; public bool Enabled => true; - public ToggleMenuOption(string text, bool defaultValue = false, Action? onToggle = null, IMenuTextSize size = IMenuTextSize.Medium) + public ToggleMenuOption(string text, bool defaultValue = false, Action? onToggle = null, IMenuTextSize size = IMenuTextSize.Medium, MenuHorizontalStyle? overflowStyle = null) { Text = text; Value = defaultValue; OnToggle = onToggle; Size = size; + OverflowStyle = overflowStyle; } - public ToggleMenuOption(string text, bool defaultValue, Action? onToggle, IMenuTextSize size = IMenuTextSize.Medium) + public ToggleMenuOption(string text, bool defaultValue, Action? onToggle, IMenuTextSize size = IMenuTextSize.Medium, MenuHorizontalStyle? overflowStyle = null) { Text = text; Value = defaultValue; OnToggleWithOption = onToggle; Size = size; + OverflowStyle = overflowStyle; } public bool ShouldShow(IPlayer player) @@ -53,12 +56,13 @@ public string GetDisplayText(IPlayer player) var status = Value ? "" : ""; + var text = (Menu as Menus.Menu)?.ApplyHorizontalStyle(Text, OverflowStyle) ?? Text; if (!CanInteract(player)) { - return $"{Text}: {status}/"; + return $"{text}: {status}/"; } - return $"{Text}: {status}"; + return $"{text}: {status}"; } public IMenuTextSize GetTextSize() diff --git a/managed/src/SwiftlyS2.Core/Modules/Players/Player.cs b/managed/src/SwiftlyS2.Core/Modules/Players/Player.cs index dc96a22f7..c7d4dc906 100644 --- a/managed/src/SwiftlyS2.Core/Modules/Players/Player.cs +++ b/managed/src/SwiftlyS2.Core/Modules/Players/Player.cs @@ -11,28 +11,28 @@ namespace SwiftlyS2.Core.Players; internal class Player : IPlayer { - private int _pid; - public Player(int pid) { - _pid = pid; + Slot = pid; } - public int PlayerID => _pid; + public int PlayerID => Slot; + + public int Slot { get; } - public bool IsFakeClient => NativePlayer.IsFakeClient(_pid); + public bool IsFakeClient => NativePlayer.IsFakeClient(Slot); - public bool IsAuthorized => NativePlayer.IsAuthorized(_pid); + public bool IsAuthorized => NativePlayer.IsAuthorized(Slot); - public uint ConnectedTime => NativePlayer.GetConnectedTime(_pid); + public uint ConnectedTime => NativePlayer.GetConnectedTime(Slot); - public Language PlayerLanguage => new(NativePlayer.GetLanguage(_pid)); + public Language PlayerLanguage => new(NativePlayer.GetLanguage(Slot)); - public ulong SteamID => NativePlayer.GetSteamID(_pid); + public ulong SteamID => NativePlayer.GetSteamID(Slot); - public ulong UnauthorizedSteamID => NativePlayer.GetUnauthorizedSteamID(_pid); + public ulong UnauthorizedSteamID => NativePlayer.GetUnauthorizedSteamID(Slot); - public CCSPlayerController Controller => new CCSPlayerControllerImpl(NativePlayer.GetController(_pid)); + public CCSPlayerController Controller => new CCSPlayerControllerImpl(NativePlayer.GetController(Slot)); public CCSPlayerController RequiredController => Controller is { IsValid: true } controller ? controller : throw new InvalidOperationException("Controller is not valid"); @@ -44,11 +44,11 @@ public Player(int pid) public CCSPlayerPawn RequiredPlayerPawn => PlayerPawn is { IsValid: true } pawn ? pawn : throw new InvalidOperationException("PlayerPawn is not valid"); - public GameButtonFlags PressedButtons => (GameButtonFlags)NativePlayer.GetPressedButtons(_pid); + public GameButtonFlags PressedButtons => (GameButtonFlags)NativePlayer.GetPressedButtons(Slot); - public string IPAddress => NativePlayer.GetIPAddress(_pid); + public string IPAddress => NativePlayer.GetIPAddress(Slot); - public VoiceFlagValue VoiceFlags { get => (VoiceFlagValue)NativeVoiceManager.GetClientVoiceFlags(_pid); set => NativeVoiceManager.SetClientVoiceFlags(_pid, (int)value); } + public VoiceFlagValue VoiceFlags { get => (VoiceFlagValue)NativeVoiceManager.GetClientVoiceFlags(Slot); set => NativeVoiceManager.SetClientVoiceFlags(Slot, (int)value); } public bool IsValid => Controller is { IsValid: true, IsHLTV: false, Connected: PlayerConnectedState.PlayerConnected } && @@ -58,60 +58,60 @@ public Player(int pid) public void ChangeTeam(Team team) { - NativePlayer.ChangeTeam(_pid, (byte)team); + NativePlayer.ChangeTeam(Slot, (byte)team); } public void ClearTransmitEntityBlocks() { - NativePlayer.ClearTransmitEntityBlocked(_pid); + NativePlayer.ClearTransmitEntityBlocked(Slot); } public ListenOverride GetListenOverride(int player) { - return (ListenOverride)NativeVoiceManager.GetClientListenOverride(_pid, player); + return (ListenOverride)NativeVoiceManager.GetClientListenOverride(Slot, player); } public bool IsTransmitEntityBlocked(int entityid) { - return NativePlayer.IsTransmitEntityBlocked(_pid, entityid); + return NativePlayer.IsTransmitEntityBlocked(Slot, entityid); } public void Kick(string reason, ENetworkDisconnectionReason gameReason) { - NativePlayer.Kick(_pid, reason, (int)gameReason); + NativePlayer.Kick(Slot, reason, (int)gameReason); } public void SendMessage(MessageType kind, string message) { - NativePlayer.SendMessage(_pid, (int)kind, message); + NativePlayer.SendMessage(Slot, (int)kind, message); } public void SetListenOverride(int player, ListenOverride listenOverride) { - NativeVoiceManager.SetClientListenOverride(_pid, player, (int)listenOverride); + NativeVoiceManager.SetClientListenOverride(Slot, player, (int)listenOverride); } public void ShouldBlockTransmitEntity(int entityid, bool shouldBlockTransmit) { - NativePlayer.ShouldBlockTransmitEntity(_pid, entityid, shouldBlockTransmit); + NativePlayer.ShouldBlockTransmitEntity(Slot, entityid, shouldBlockTransmit); } public void SwitchTeam(Team team) { - NativePlayer.SwitchTeam(_pid, (byte)team); + NativePlayer.SwitchTeam(Slot, (byte)team); } public void TakeDamage(CTakeDamageInfo damageInfo) { unsafe { - NativePlayer.TakeDamage(_pid, (nint)(&damageInfo)); + NativePlayer.TakeDamage(Slot, (nint)(&damageInfo)); } } public void Teleport(Vector pos, QAngle angle, Vector velocity) { - NativePlayer.Teleport(_pid, pos, angle, velocity); + NativePlayer.Teleport(Slot, pos, angle, velocity); } public void Respawn() @@ -121,13 +121,12 @@ public void Respawn() public void ExecuteCommand(string command) { - NativePlayer.ExecuteCommand(_pid, command); + NativePlayer.ExecuteCommand(Slot, command); } public bool Equals(IPlayer? other) { - if (other is null) return false; - return PlayerID == other.PlayerID; + return other is not null && PlayerID == other.PlayerID; } public override bool Equals(object? obj) @@ -142,12 +141,8 @@ public override int GetHashCode() public static bool operator ==(Player? left, Player? right) { - if (left is null) return right is null; - return left.Equals(right); + return left is not null && right is not null && left.Equals(right); } - public static bool operator !=(Player? left, Player? right) - { - return !(left == right); - } + public static bool operator !=(Player left, Player right) => !(left == right); } \ No newline at end of file diff --git a/managed/src/SwiftlyS2.Core/Modules/Plugins/PluginContext.cs b/managed/src/SwiftlyS2.Core/Modules/Plugins/PluginContext.cs index 6a8d04191..0afd021fc 100644 --- a/managed/src/SwiftlyS2.Core/Modules/Plugins/PluginContext.cs +++ b/managed/src/SwiftlyS2.Core/Modules/Plugins/PluginContext.cs @@ -7,8 +7,8 @@ namespace SwiftlyS2.Core.Plugins; -internal class PluginContext : IDisposable { - +internal class PluginContext : IDisposable +{ public SwiftlyCore? Core { get; set; } public PluginMetadata? Metadata { get; set; } @@ -20,10 +20,11 @@ internal class PluginContext : IDisposable { public PluginLoader? Loader { get; set; } - public void Dispose() { + public void Dispose() + { Plugin?.Unload(); Loader?.Dispose(); + Core?.MenuManager?.CloseAllMenus(); Core?.Dispose(); } - } \ No newline at end of file diff --git a/managed/src/SwiftlyS2.Core/Modules/Plugins/PluginManager.cs b/managed/src/SwiftlyS2.Core/Modules/Plugins/PluginManager.cs index b69d2b2d0..bc19dbb98 100644 --- a/managed/src/SwiftlyS2.Core/Modules/Plugins/PluginManager.cs +++ b/managed/src/SwiftlyS2.Core/Modules/Plugins/PluginManager.cs @@ -397,7 +397,7 @@ private void RebuildSharedServices() return context; } - public void UnloadPlugin(string id) + public bool UnloadPlugin(string id) { var context = _Plugins .Where(p => p.Status == PluginStatus.Loaded) @@ -405,14 +405,15 @@ public void UnloadPlugin(string id) if (context == null) { _Logger.LogWarning("Plugin not found or not loaded: " + id); - return; + return false; } context.Dispose(); context.Status = PluginStatus.Unloaded; + return true; } - public void LoadPluginById(string id) + public bool LoadPluginById(string id) { var context = _Plugins .Where(p => p.Status == PluginStatus.Unloaded) @@ -433,7 +434,7 @@ public void LoadPluginById(string id) if (context == null) { _Logger.LogWarning("Plugin not found: " + id); - return; + return false; } } else @@ -444,15 +445,22 @@ public void LoadPluginById(string id) } RebuildSharedServices(); + return true; } public void ReloadPlugin(string id) { _Logger.LogInformation("Reloading plugin " + id); - UnloadPlugin(id); - LoadPluginById(id); - RebuildSharedServices(); + if (!UnloadPlugin(id)) + { + return; + } + + if (!LoadPluginById(id)) + { + RebuildSharedServices(); + } _Logger.LogInformation("Reloaded plugin " + id); } diff --git a/managed/src/SwiftlyS2.Core/Modules/Schemas/Extensions/CBaseEntityImpl.cs b/managed/src/SwiftlyS2.Core/Modules/Schemas/Extensions/CBaseEntityImpl.cs index f151233a2..6c3cdf4e0 100644 --- a/managed/src/SwiftlyS2.Core/Modules/Schemas/Extensions/CBaseEntityImpl.cs +++ b/managed/src/SwiftlyS2.Core/Modules/Schemas/Extensions/CBaseEntityImpl.cs @@ -10,7 +10,7 @@ public CEntitySubclassVDataBase VData { get { - return new CEntitySubclassVDataBaseImpl((nint)NativeSchema.GetVData(_Handle)); + return new CEntitySubclassVDataBaseImpl(NativeSchema.GetVData(_Handle)); } } diff --git a/managed/src/SwiftlyS2.Core/Modules/Schemas/Extensions/CEntityInstance.cs b/managed/src/SwiftlyS2.Core/Modules/Schemas/Extensions/CEntityInstance.cs index 34da97ca7..0df411f07 100644 --- a/managed/src/SwiftlyS2.Core/Modules/Schemas/Extensions/CEntityInstance.cs +++ b/managed/src/SwiftlyS2.Core/Modules/Schemas/Extensions/CEntityInstance.cs @@ -35,7 +35,7 @@ public partial interface CEntityInstance { /// Input value. /// Activator entity. Nullable. /// Caller entity. Nullable. - /// Delay in seconds. + /// Delay in seconds.x public void AddEntityIOEvent(string input, T value, CEntityInstance? activator = null, CEntityInstance? caller = null, float delay = 0f); /// @@ -44,6 +44,25 @@ public partial interface CEntityInstance { /// Entity key values. Nullable. public void DispatchSpawn( CEntityKeyValues? entityKV = null ); + /// + /// Set the transmit state of the entity for one player. + /// + /// Whether the entity should be transmitting. + /// The player ID to set the transmit state for. + public void SetTransmitState( bool transmitting , int playerId ); + + /// + /// Set the global transmit state of the entity. + /// + /// Whether the entity should be transmitting. + public void SetTransmitState( bool transmitting ); + + /// + /// Check if the entity is transmitting for one player. + /// + /// The player ID to check the transmit state for. + public bool IsTransmitting( int playerId ); + /// /// Despawn the entity. public void Despawn(); diff --git a/managed/src/SwiftlyS2.Core/Modules/Schemas/Extensions/CEntityInstanceImpl.cs b/managed/src/SwiftlyS2.Core/Modules/Schemas/Extensions/CEntityInstanceImpl.cs index eb4046c9c..097ff1615 100644 --- a/managed/src/SwiftlyS2.Core/Modules/Schemas/Extensions/CEntityInstanceImpl.cs +++ b/managed/src/SwiftlyS2.Core/Modules/Schemas/Extensions/CEntityInstanceImpl.cs @@ -75,6 +75,21 @@ public void AddEntityIOEvent(string input, T value, CEntityInstance? activato } } + public void SetTransmitState( bool transmitting , int playerId ) { + NativePlayer.ShouldBlockTransmitEntity(playerId, (int)Index, transmitting); + + } + + public void SetTransmitState( bool transmitting ) + { + NativePlayerManager.ShouldBlockTransmitEntity((int)Index, transmitting); + } + + public bool IsTransmitting( int playerId ) + { + return NativePlayer.IsTransmitEntityBlocked(playerId, (int)Index); + } + public void DispatchSpawn(CEntityKeyValues? entityKV = null) { NativeEntitySystem.Spawn(Address, entityKV?.Address ?? nint.Zero); } diff --git a/managed/src/SwiftlyS2.Core/Modules/Schemas/Extensions/CPlayer_ItemServices.cs b/managed/src/SwiftlyS2.Core/Modules/Schemas/Extensions/CPlayer_ItemServices.cs index c0ff025f1..4f73b22f9 100644 --- a/managed/src/SwiftlyS2.Core/Modules/Schemas/Extensions/CPlayer_ItemServices.cs +++ b/managed/src/SwiftlyS2.Core/Modules/Schemas/Extensions/CPlayer_ItemServices.cs @@ -19,6 +19,13 @@ public partial interface CPlayer_ItemServices { /// The item that was given. public T GiveItem(string itemDesignerName) where T : ISchemaClass; + /// + /// Give an item to the player. + /// + /// The designer name of the item to give. + public void GiveItem(string itemDesignerName); + + /// /// Drop the item that player is holding. /// diff --git a/managed/src/SwiftlyS2.Core/Modules/Schemas/Extensions/CPlayer_ItemServicesImpl.cs b/managed/src/SwiftlyS2.Core/Modules/Schemas/Extensions/CPlayer_ItemServicesImpl.cs index 4769ef756..eaec2c16e 100644 --- a/managed/src/SwiftlyS2.Core/Modules/Schemas/Extensions/CPlayer_ItemServicesImpl.cs +++ b/managed/src/SwiftlyS2.Core/Modules/Schemas/Extensions/CPlayer_ItemServicesImpl.cs @@ -18,6 +18,10 @@ public T GiveItem(string itemDesignerName) where T : ISchemaClass { return T.From(GameFunctions.CCSPlayer_ItemServices_GiveNamedItem(Address, itemDesignerName)); } + public void GiveItem(string itemDesignerName) { + GameFunctions.CCSPlayer_ItemServices_GiveNamedItem(Address, itemDesignerName); + } + public void RemoveItems() { GameFunctions.CCSPlayer_ItemServices_RemoveWeapons(Address); } diff --git a/managed/src/SwiftlyS2.Core/Natives/GameFunctions.cs b/managed/src/SwiftlyS2.Core/Natives/GameFunctions.cs index 0962df5b2..6a2a6418c 100644 --- a/managed/src/SwiftlyS2.Core/Natives/GameFunctions.cs +++ b/managed/src/SwiftlyS2.Core/Natives/GameFunctions.cs @@ -2,18 +2,20 @@ using System.Text; using Spectre.Console; using SwiftlyS2.Shared.Natives; +using SwiftlyS2.Shared.SchemaDefinitions; namespace SwiftlyS2.Core.Natives; internal static class GameFunctions { - public static unsafe delegate* unmanaged pCTakeDamageInfo_Constructor; - public static unsafe delegate* unmanaged pTraceShape; - public static unsafe delegate* unmanaged pTracePlayerBBox; - public static unsafe delegate* unmanaged pSetModel; - public static unsafe delegate* unmanaged pSetPlayerControllerPawn; - public static unsafe delegate* unmanaged pSetOrAddAttribute; - public static unsafe delegate* unmanaged pGetWeaponCSDataFromKey; + public static unsafe delegate* unmanaged< CTakeDamageInfo*, nint, nint, nint, Vector*, Vector*, float, int, int, void*, void > pCTakeDamageInfo_Constructor; + public static unsafe delegate* unmanaged< nint, Ray_t*, Vector, Vector, CTraceFilter*, CGameTrace*, void > pTraceShape; + public static unsafe delegate* unmanaged< Vector, Vector, BBox_t, CTraceFilter*, CGameTrace*, void > pTracePlayerBBox; + public static unsafe delegate* unmanaged< nint, IntPtr, void > pSetModel; + public static unsafe delegate* unmanaged< nint, nint, byte, byte, byte, byte, void > pSetPlayerControllerPawn; + public static unsafe delegate* unmanaged< nint, nint, float, void > pSetOrAddAttribute; + public static unsafe delegate* unmanaged< int, nint, nint > pGetWeaponCSDataFromKey; + public static unsafe delegate* unmanaged< nint, uint, nint, byte, CUtlSymbolLarge, byte, int, nint, nint, void > pDispatchParticleEffect; public static int TeleportOffset => NativeOffsets.Fetch("CBaseEntity::Teleport"); public static int CommitSuicideOffset => NativeOffsets.Fetch("CBasePlayerPawn::CommitSuicide"); public static int GetSkeletonInstanceOffset => NativeOffsets.Fetch("CGameSceneNode::GetSkeletonInstance"); @@ -31,17 +33,42 @@ public static void Initialize() { unsafe { - pCTakeDamageInfo_Constructor = (delegate* unmanaged)NativeSignatures.Fetch("CTakeDamageInfo::Constructor"); - pTraceShape = (delegate* unmanaged)NativeSignatures.Fetch("TraceShape"); - pTracePlayerBBox = (delegate* unmanaged)NativeSignatures.Fetch("TracePlayerBBox"); - pSetModel = (delegate* unmanaged)NativeSignatures.Fetch("CBaseModelEntity::SetModel"); - pSetPlayerControllerPawn = (delegate* unmanaged)NativeSignatures.Fetch("CBasePlayerController::SetPawn"); - pSetOrAddAttribute = (delegate* unmanaged)NativeSignatures.Fetch("CAttributeList::SetOrAddAttributeValueByName"); - pGetWeaponCSDataFromKey = (delegate* unmanaged)NativeSignatures.Fetch("GetWeaponCSDataFromKey"); + pCTakeDamageInfo_Constructor = (delegate* unmanaged< CTakeDamageInfo*, nint, nint, nint, Vector*, Vector*, float, int, int, void*, void >)NativeSignatures.Fetch("CTakeDamageInfo::Constructor"); + pTraceShape = (delegate* unmanaged< nint, Ray_t*, Vector, Vector, CTraceFilter*, CGameTrace*, void >)NativeSignatures.Fetch("TraceShape"); + pTracePlayerBBox = (delegate* unmanaged< Vector, Vector, BBox_t, CTraceFilter*, CGameTrace*, void >)NativeSignatures.Fetch("TracePlayerBBox"); + pSetModel = (delegate* unmanaged< nint, IntPtr, void >)NativeSignatures.Fetch("CBaseModelEntity::SetModel"); + pSetPlayerControllerPawn = (delegate* unmanaged< nint, nint, byte, byte, byte, byte, void >)NativeSignatures.Fetch("CBasePlayerController::SetPawn"); + pSetOrAddAttribute = (delegate* unmanaged< nint, IntPtr, float, void >)NativeSignatures.Fetch("CAttributeList::SetOrAddAttributeValueByName"); + pGetWeaponCSDataFromKey = (delegate* unmanaged< int, nint, nint >)NativeSignatures.Fetch("GetWeaponCSDataFromKey"); + pDispatchParticleEffect = (delegate* unmanaged< nint, uint, nint, byte, CUtlSymbolLarge, byte, int, nint, nint, void >)NativeSignatures.Fetch("DispatchParticleEffect"); } } - public unsafe static nint GetWeaponCSDataFromKey(int unknown, string key) + public unsafe static void DispatchParticleEffect( string particleName, uint attachmentType, nint entity, byte attachmentPoint, CUtlSymbolLarge attachmentName, bool resetAllParticlesOnEntity, int splitScreenSlot, CRecipientFilter filter ) + { + try + { + unsafe + { + var pool = ArrayPool.Shared; + var nameLength = Encoding.UTF8.GetByteCount(particleName); + var nameBuffer = pool.Rent(nameLength + 1); + _ = Encoding.UTF8.GetBytes(particleName, nameBuffer); + nameBuffer[nameLength] = 0; + fixed (byte* pParticleName = nameBuffer) + { + pDispatchParticleEffect((nint)pParticleName, attachmentType, entity, attachmentPoint, attachmentName, (byte)(resetAllParticlesOnEntity ? 1 : 0), splitScreenSlot, (nint)(&filter), IntPtr.Zero); + pool.Return(nameBuffer); + } + } + } + catch (Exception e) + { + AnsiConsole.WriteException(e); + } + } + + public unsafe static nint GetWeaponCSDataFromKey( int unknown, string key ) { try { @@ -65,14 +92,14 @@ public unsafe static nint GetWeaponCSDataFromKey(int unknown, string key) } } - public unsafe static nint FindPickerEntity(nint handle, nint controller) + public unsafe static nint FindPickerEntity( nint handle, nint controller ) { try { unsafe { void*** ppVTable = (void***)handle; - var pFindPickerEntity = (delegate* unmanaged)ppVTable[0][FindPickerEntityOffset]; + var pFindPickerEntity = (delegate* unmanaged< nint, nint, nint, nint >)ppVTable[0][FindPickerEntityOffset]; return pFindPickerEntity(handle, controller, IntPtr.Zero); } } @@ -83,14 +110,14 @@ public unsafe static nint FindPickerEntity(nint handle, nint controller) return 0; } - public unsafe static nint GetSkeletonInstance(nint handle) + public unsafe static nint GetSkeletonInstance( nint handle ) { try { unsafe { void*** ppVTable = (void***)handle; - var pSkeletonInstance = (delegate* unmanaged)ppVTable[0][GetSkeletonInstanceOffset]; + var pSkeletonInstance = (delegate* unmanaged< nint, nint >)ppVTable[0][GetSkeletonInstanceOffset]; return pSkeletonInstance(handle); } } @@ -101,14 +128,14 @@ public unsafe static nint GetSkeletonInstance(nint handle) return 0; } - public unsafe static void PawnCommitSuicide(nint pPawn, bool bExplode, bool bForce) + public unsafe static void PawnCommitSuicide( nint pPawn, bool bExplode, bool bForce ) { try { unsafe { void*** ppVTable = (void***)pPawn; - var pCommitSuicide = (delegate* unmanaged)ppVTable[0][CommitSuicideOffset]; + var pCommitSuicide = (delegate* unmanaged< nint, byte, byte, void >)ppVTable[0][CommitSuicideOffset]; pCommitSuicide(pPawn, (byte)(bExplode ? 1 : 0), (byte)(bForce ? 1 : 0)); } } @@ -118,7 +145,7 @@ public unsafe static void PawnCommitSuicide(nint pPawn, bool bExplode, bool bFor } } - public unsafe static void SetPlayerControllerPawn(nint pController, nint pPawn, bool b1, bool b2, bool b3, bool b4) + public unsafe static void SetPlayerControllerPawn( nint pController, nint pPawn, bool b1, bool b2, bool b3, bool b4 ) { try { @@ -133,7 +160,7 @@ public unsafe static void SetPlayerControllerPawn(nint pController, nint pPawn, } } - public unsafe static void SetModel(nint pEntity, string model) + public unsafe static void SetModel( nint pEntity, string model ) { try { @@ -169,7 +196,7 @@ public unsafe static void Teleport( unsafe { void*** ppVTable = (void***)pEntity; - var pTeleport = (delegate* unmanaged)ppVTable[0][TeleportOffset]; + var pTeleport = (delegate* unmanaged< nint, Vector*, QAngle*, Vector*, void >)ppVTable[0][TeleportOffset]; pTeleport(pEntity, vecPosition, vecAngle, vecVelocity); } } @@ -248,14 +275,14 @@ public unsafe static void CTakeDamageInfoConstructor( } } - public unsafe static void CCSPlayer_ItemServices_RemoveWeapons(nint pThis) + public unsafe static void CCSPlayer_ItemServices_RemoveWeapons( nint pThis ) { try { unsafe { void*** ppVTable = (void***)pThis; - var pRemoveWeapons = (delegate* unmanaged)ppVTable[0][RemoveWeaponsOffset]; + var pRemoveWeapons = (delegate* unmanaged< nint, void >)ppVTable[0][RemoveWeaponsOffset]; pRemoveWeapons(pThis); } } @@ -265,14 +292,14 @@ public unsafe static void CCSPlayer_ItemServices_RemoveWeapons(nint pThis) } } - public unsafe static nint CCSPlayer_ItemServices_GiveNamedItem(nint pThis, string name) + public unsafe static nint CCSPlayer_ItemServices_GiveNamedItem( nint pThis, string name ) { try { unsafe { void*** ppVTable = (void***)pThis; - var pGiveNamedItem = (delegate* unmanaged)ppVTable[0][GiveNamedItemOffset]; + var pGiveNamedItem = (delegate* unmanaged< nint, nint, nint >)ppVTable[0][GiveNamedItemOffset]; var pool = ArrayPool.Shared; var nameLength = Encoding.UTF8.GetByteCount(name); var nameBuffer = pool.Rent(nameLength + 1); @@ -291,14 +318,14 @@ public unsafe static nint CCSPlayer_ItemServices_GiveNamedItem(nint pThis, strin } } - public unsafe static void CCSPlayer_ItemServices_DropActiveItem(nint pThis, Vector momentum) + public unsafe static void CCSPlayer_ItemServices_DropActiveItem( nint pThis, Vector momentum ) { try { unsafe { void*** ppVTable = (void***)pThis; - var pDropActiveItem = (delegate* unmanaged)ppVTable[0][DropActiveItemOffset]; + var pDropActiveItem = (delegate* unmanaged< nint, Vector*, void >)ppVTable[0][DropActiveItemOffset]; pDropActiveItem(pThis, &momentum); } } @@ -308,14 +335,14 @@ public unsafe static void CCSPlayer_ItemServices_DropActiveItem(nint pThis, Vect } } - public unsafe static void CCSPlayer_WeaponServices_DropWeapon(nint pThis, nint pWeapon) + public unsafe static void CCSPlayer_WeaponServices_DropWeapon( nint pThis, nint pWeapon ) { try { unsafe { void*** ppVTable = (void***)pThis; - var pDropWeapon = (delegate* unmanaged)ppVTable[0][DropWeaponOffset]; + var pDropWeapon = (delegate* unmanaged< nint, nint, void >)ppVTable[0][DropWeaponOffset]; pDropWeapon(pThis, pWeapon); } } @@ -325,14 +352,14 @@ public unsafe static void CCSPlayer_WeaponServices_DropWeapon(nint pThis, nint p } } - public unsafe static void CCSPlayer_WeaponServices_SelectWeapon(nint pThis, nint pWeapon) + public unsafe static void CCSPlayer_WeaponServices_SelectWeapon( nint pThis, nint pWeapon ) { try { unsafe { void*** ppVTable = (void***)pThis; - var pSelectWeapon = (delegate* unmanaged)ppVTable[0][SelectWeaponOffset]; + var pSelectWeapon = (delegate* unmanaged< nint, nint, void >)ppVTable[0][SelectWeaponOffset]; pSelectWeapon(pThis, pWeapon); } } @@ -342,7 +369,7 @@ public unsafe static void CCSPlayer_WeaponServices_SelectWeapon(nint pThis, nint } } - public unsafe static void CEntityResourceManifest_AddResource(nint pThis, string path) + public unsafe static void CEntityResourceManifest_AddResource( nint pThis, string path ) { try { @@ -354,7 +381,7 @@ public unsafe static void CEntityResourceManifest_AddResource(nint pThis, string Encoding.UTF8.GetBytes(path, pathBuffer); pathBuffer[pathLength] = 0; void*** ppVTable = (void***)pThis; - var pAddResource = (delegate* unmanaged)ppVTable[0][AddResourceOffset]; + var pAddResource = (delegate* unmanaged< nint, nint, void >)ppVTable[0][AddResourceOffset]; fixed (byte* pPath = pathBuffer) { pAddResource(pThis, (IntPtr)pPath); @@ -368,7 +395,7 @@ public unsafe static void CEntityResourceManifest_AddResource(nint pThis, string } } - public unsafe static void SetOrAddAttribute(nint handle, string name, float value) + public unsafe static void SetOrAddAttribute( nint handle, string name, float value ) { try { @@ -392,14 +419,14 @@ public unsafe static void SetOrAddAttribute(nint handle, string name, float valu } } - public unsafe static void CBaseEntity_CollisionRulesChanged(nint pThis) + public unsafe static void CBaseEntity_CollisionRulesChanged( nint pThis ) { try { unsafe { void*** ppVTable = (void***)pThis; - var pCollisionRulesChanged = (delegate* unmanaged)ppVTable[0][CollisionRulesChangedOffset]; + var pCollisionRulesChanged = (delegate* unmanaged< nint, void >)ppVTable[0][CollisionRulesChangedOffset]; pCollisionRulesChanged(pThis); } } @@ -409,14 +436,14 @@ public unsafe static void CBaseEntity_CollisionRulesChanged(nint pThis) } } - public unsafe static void CCSPlayerController_Respawn(nint pThis) + public unsafe static void CCSPlayerController_Respawn( nint pThis ) { try { unsafe { void*** ppVTable = (void***)pThis; - var pRespawn = (delegate* unmanaged)ppVTable[0][RespawnOffset]; + var pRespawn = (delegate* unmanaged< nint, void >)ppVTable[0][RespawnOffset]; pRespawn(pThis); } } diff --git a/managed/src/SwiftlyS2.Core/Services/CoreHookService.cs b/managed/src/SwiftlyS2.Core/Services/CoreHookService.cs index 703f153db..0786594bf 100644 --- a/managed/src/SwiftlyS2.Core/Services/CoreHookService.cs +++ b/managed/src/SwiftlyS2.Core/Services/CoreHookService.cs @@ -134,7 +134,10 @@ private void HookTouch() { return (pBaseEntity, pOtherEntity) => { - EventPublisher.InvokeOnEntityTouchHook(new OnEntityTouchHookEvent { Entity = new CBaseEntityImpl(pBaseEntity), OtherEntity = new CBaseEntityImpl(pOtherEntity), TouchType = EntityTouchType.StartTouch }); + var entity = new CBaseEntityImpl(pBaseEntity); + var otherEntity = new CBaseEntityImpl(pOtherEntity); + EventPublisher.InvokeOnEntityStartTouch(new OnEntityStartTouchEvent { Entity = entity, OtherEntity = otherEntity }); + EventPublisher.InvokeOnEntityTouchHook(new OnEntityTouchHookEvent { Entity = entity, OtherEntity = otherEntity, TouchType = EntityTouchType.StartTouch }); return next()(pBaseEntity, pOtherEntity); }; }); @@ -143,7 +146,10 @@ private void HookTouch() { return (pBaseEntity, pOtherEntity) => { - EventPublisher.InvokeOnEntityTouchHook(new OnEntityTouchHookEvent { Entity = new CBaseEntityImpl(pBaseEntity), OtherEntity = new CBaseEntityImpl(pOtherEntity), TouchType = EntityTouchType.Touch }); + var entity = new CBaseEntityImpl(pBaseEntity); + var otherEntity = new CBaseEntityImpl(pOtherEntity); + EventPublisher.InvokeOnEntityTouch(new OnEntityTouchEvent { Entity = entity, OtherEntity = otherEntity }); + EventPublisher.InvokeOnEntityTouchHook(new OnEntityTouchHookEvent { Entity = entity, OtherEntity = otherEntity, TouchType = EntityTouchType.Touch }); return next()(pBaseEntity, pOtherEntity); }; }); @@ -152,7 +158,10 @@ private void HookTouch() { return (pBaseEntity, pOtherEntity) => { - EventPublisher.InvokeOnEntityTouchHook(new OnEntityTouchHookEvent { Entity = new CBaseEntityImpl(pBaseEntity), OtherEntity = new CBaseEntityImpl(pOtherEntity), TouchType = EntityTouchType.EndTouch }); + var entity = new CBaseEntityImpl(pBaseEntity); + var otherEntity = new CBaseEntityImpl(pOtherEntity); + EventPublisher.InvokeOnEntityEndTouch(new OnEntityEndTouchEvent { Entity = entity, OtherEntity = otherEntity }); + EventPublisher.InvokeOnEntityTouchHook(new OnEntityTouchHookEvent { Entity = entity, OtherEntity = otherEntity, TouchType = EntityTouchType.EndTouch }); return next()(pBaseEntity, pOtherEntity); }; }); diff --git a/managed/src/SwiftlyS2.Core/Services/PluginConfigurationService.cs b/managed/src/SwiftlyS2.Core/Services/PluginConfigurationService.cs index 2bd92c92f..a12ae461f 100644 --- a/managed/src/SwiftlyS2.Core/Services/PluginConfigurationService.cs +++ b/managed/src/SwiftlyS2.Core/Services/PluginConfigurationService.cs @@ -1,47 +1,55 @@ using System.Data.Common; -using System.Reflection; using System.Text.Json; using Microsoft.Extensions.Configuration; using SwiftlyS2.Core.Natives; using SwiftlyS2.Core.Services; using SwiftlyS2.Shared.Services; +using Tomlyn; namespace SwiftlyS2.Core.Services; -internal class PluginConfigurationService : IPluginConfigurationService { +internal class PluginConfigurationService : IPluginConfigurationService +{ private ConfigurationService _ConfigurationService { get; init; } private CoreContext _Id { get; init; } private IConfigurationManager? _Manager { get; set; } - public bool BasePathExists { + public bool BasePathExists + { get => Path.Exists(BasePath); } - public PluginConfigurationService(CoreContext id, ConfigurationService configurationService) { + public PluginConfigurationService(CoreContext id, ConfigurationService configurationService) + { _Id = id; _ConfigurationService = configurationService; } public string BasePath => Path.Combine(_ConfigurationService.GetConfigRoot(), "plugins", _Id.Name); - public string GetRoot() { + public string GetRoot() + { var dir = Path.Combine(_ConfigurationService.GetConfigRoot(), "plugins", _Id.Name); - if (!Directory.Exists(dir)) { + if (!Directory.Exists(dir)) + { Directory.CreateDirectory(dir); } return dir; } - public string GetConfigPath(string name) { + public string GetConfigPath(string name) + { return Path.Combine(GetRoot(), name); } - public IPluginConfigurationService InitializeWithTemplate(string name, string templatePath) { + public IPluginConfigurationService InitializeWithTemplate(string name, string templatePath) + { var configPath = GetConfigPath(name); - if (File.Exists(configPath)) { + if (File.Exists(configPath)) + { return this; } @@ -54,7 +62,8 @@ public IPluginConfigurationService InitializeWithTemplate(string name, string te var templateAbsPath = Path.Combine(_Id.BaseDirectory, "resources", "templates", templatePath); - if (!File.Exists(templateAbsPath)) { + if (!File.Exists(templateAbsPath)) + { throw new FileNotFoundException($"Template file not found: {templateAbsPath}"); } @@ -62,11 +71,13 @@ public IPluginConfigurationService InitializeWithTemplate(string name, string te return this; } - public IPluginConfigurationService InitializeJsonWithModel(string name, string sectionName) where T : class, new() { - + public IPluginConfigurationService InitializeJsonWithModel(string name, string sectionName) where T : class, new() + { + var configPath = GetConfigPath(name); - if (File.Exists(configPath)) { + if (File.Exists(configPath)) + { return this; } @@ -75,7 +86,6 @@ public IPluginConfigurationService InitializeWithTemplate(string name, string te { Directory.CreateDirectory(dir); } - File.Create(configPath).Close(); var config = new T(); @@ -84,7 +94,8 @@ public IPluginConfigurationService InitializeWithTemplate(string name, string te [sectionName] = config }; - var options = new JsonSerializerOptions { + var options = new JsonSerializerOptions + { WriteIndented = true, IncludeFields = true, PropertyNamingPolicy = null @@ -96,21 +107,55 @@ public IPluginConfigurationService InitializeWithTemplate(string name, string te return this; } - public IPluginConfigurationService Configure(Action configure) { + public IPluginConfigurationService InitializeTomlWithModel(string name, string sectionName) where T : class, new() + { + + var configPath = GetConfigPath(name); + + if (File.Exists(configPath)) + { + return this; + } + + var dir = Path.GetDirectoryName(configPath); + if (dir is not null) + { + Directory.CreateDirectory(dir); + } + + var config = new T(); + + var wrapped = new Dictionary + { + [sectionName] = config + }; + + var tomlString = Toml.FromModel(wrapped); + File.WriteAllText(configPath, tomlString); + + return this; + } + + public IPluginConfigurationService Configure(Action configure) + { configure(Manager); return this; } - public IConfigurationManager Manager { - get { - if (!BasePathExists) { - throw new Exception("Base path does not exist in file system. Please call InitializeWithTemplate or InitializeJsonWithModel before using the Manager."); + public IConfigurationManager Manager + { + get + { + if (!BasePathExists) + { + throw new Exception("Base path does not exist in file system. Please call InitializeWithTemplate, InitializeJsonWithModel or InitializeTomlWithModel before using the Manager."); } - if (_Manager is null) { + if (_Manager is null) + { _Manager = new ConfigurationManager(); _Manager.SetBasePath(BasePath); } return _Manager; } } -} \ No newline at end of file +} diff --git a/managed/src/SwiftlyS2.Shared/Helper.cs b/managed/src/SwiftlyS2.Shared/Helper.cs index 88a2e5dbd..49cd0b2ba 100644 --- a/managed/src/SwiftlyS2.Shared/Helper.cs +++ b/managed/src/SwiftlyS2.Shared/Helper.cs @@ -85,4 +85,39 @@ public static T AsSchema(nint ptr) where T : ISchemaClass { return T.From(ptr); } + + /// + /// Estimates the display width of a character based on its type. + /// Inspired by: https://github.com/spectreconsole/wcwidth + /// + /// The character to measure. + /// The estimated display width in relative units. + public static float GetCharWidth(char c) => c switch + { + >= '\u4E00' and <= '\u9FFF' => 2.0f, // CJK Unified Ideographs + >= '\u3000' and <= '\u303F' => 2.0f, // CJK Symbols and Punctuation + >= '\uFF00' and <= '\uFFEF' => 2.0f, // Halfwidth and Fullwidth Forms + >= '\uAC00' and <= '\uD7AF' => 2.2f, // Hangul Syllables + >= '\u1100' and <= '\u11FF' => 2.2f, // Hangul Jamo + >= '\u3130' and <= '\u318F' => 2.2f, // Hangul Compatibility Jamo + >= '\u3040' and <= '\u309F' => 2.05f, // Hiragana + >= '\u30A0' and <= '\u30FF' => 2.05f, // Katakana + >= '\u31F0' and <= '\u31FF' => 2.05f, // Katakana Phonetic Extensions + >= 'A' and <= 'Z' => 1.2f, + >= 'a' and <= 'z' => 1.0f, + >= '0' and <= '9' => 1.0f, + ' ' => 0.5f, + >= '!' and <= '/' => 0.8f, + >= ':' and <= '@' => 0.8f, + >= '[' and <= '`' => 0.8f, + >= '{' and <= '~' => 0.8f, + _ => 1.0f + }; + + /// + /// Estimates the display width of a text string based on character types. + /// + /// The text string to measure. + /// The estimated display width in relative units. + public static float EstimateTextWidth(string text) => text.Sum(GetCharWidth); } \ No newline at end of file diff --git a/managed/src/SwiftlyS2.Shared/HtmlGradient.cs b/managed/src/SwiftlyS2.Shared/HtmlGradient.cs new file mode 100644 index 000000000..cb6c6310a --- /dev/null +++ b/managed/src/SwiftlyS2.Shared/HtmlGradient.cs @@ -0,0 +1,78 @@ +namespace SwiftlyS2.Shared; + +/// +/// Provides utility methods for generating HTML text with gradient color effects. +/// +public static class HtmlGradient +{ + /// + /// Generates gradient colored text by interpolating between two colors. + /// + /// The text to apply gradient to. + /// The starting color in hex format (e.g., "#FF0000"). + /// The ending color in hex format (e.g., "#0000FF"). + /// HTML string with each character wrapped in a colored font tag. + public static string GenerateGradientText(string text, string startColor, string endColor) + { + if (string.IsNullOrEmpty(text)) + { + return text; + } + + var (startR, startG, startB) = ParseHexColor(startColor); + var (endR, endG, endB) = ParseHexColor(endColor); + var length = text.Length; + + return string.Concat(text.Select((ch, i) => + { + var ratio = length > 1 ? (float)i / (length - 1) : 0f; + var r = (int)(startR + (endR - startR) * ratio); + var g = (int)(startG + (endG - startG) * ratio); + var b = (int)(startB + (endB - startB) * ratio); + return $"{ch}"; + })); + } + + /// + /// Generates gradient colored text by interpolating across multiple color stops. + /// + /// The text to apply gradient to. + /// Array of color stops in hex format (e.g., "#FF0000", "#00FF00", "#0000FF"). + /// HTML string with each character wrapped in a colored font tag. + public static string GenerateGradientText(string text, params string[] colors) => (text, colors) switch + { + (null or "", _) => string.Empty, + (_, []) => text, + (_, [var single]) => $"{text}", + _ => GenerateMultiColorGradient(text, colors) + }; + + private static string GenerateMultiColorGradient(string text, string[] colors) + { + var parsedColors = colors.Select(ParseHexColor).ToArray(); + var length = text.Length; + + return string.Concat(text.Select((ch, i) => + { + var position = length > 1 ? (float)i / (length - 1) : 0f; + var segmentIndex = position * (parsedColors.Length - 1); + var startIdx = (int)Math.Floor(segmentIndex); + var endIdx = Math.Min(startIdx + 1, parsedColors.Length - 1); + var ratio = segmentIndex - startIdx; + + var (startR, startG, startB) = parsedColors[startIdx]; + var (endR, endG, endB) = parsedColors[endIdx]; + + var r = (int)(startR + (endR - startR) * ratio); + var g = (int)(startG + (endG - startG) * ratio); + var b = (int)(startB + (endB - startB) * ratio); + + return $"{ch}"; + })); + } + + private static (int R, int G, int B) ParseHexColor(string hex) => + hex.TrimStart('#') is { Length: 6 } h + ? (Convert.ToInt32(h[..2], 16), Convert.ToInt32(h[2..4], 16), Convert.ToInt32(h[4..6], 16)) + : (255, 255, 255); +} \ No newline at end of file diff --git a/managed/src/SwiftlyS2.Shared/Modules/Engine/IEngineService.cs b/managed/src/SwiftlyS2.Shared/Modules/Engine/IEngineService.cs index e3b078a4b..1ab0441d5 100644 --- a/managed/src/SwiftlyS2.Shared/Modules/Engine/IEngineService.cs +++ b/managed/src/SwiftlyS2.Shared/Modules/Engine/IEngineService.cs @@ -1,4 +1,5 @@ using SwiftlyS2.Shared.Natives; +using SwiftlyS2.Shared.SchemaDefinitions; namespace SwiftlyS2.Shared.Services; @@ -7,61 +8,74 @@ public interface IEngineService /// /// The IP address of the server. /// - string ServerIP { get; } + public string ServerIP { get; } /// /// Gets the map that the server is running /// [Obsolete("Use GlobalVars.MapName instead.")] - string Map { get; } + public string Map { get; } /// /// Gets a reference to the global variables structure. /// - ref CGlobalVars GlobalVars { get; } + public ref CGlobalVars GlobalVars { get; } /// /// Determines whether the specified map string represents a valid map in server files. /// /// The map string to validate. It also supports Workshop ID. /// true if the map is valid; otherwise, false. - bool IsMapValid(string map); + public bool IsMapValid( string map ); /// /// Gets the maximum number of players allowed in the game. /// [Obsolete("Use GlobalVars.MaxClients instead.")] - int MaxPlayers { get; } + public int MaxPlayers { get; } /// /// Executes the specified command string in the current context. /// /// The command to execute. Cannot be null or empty. - void ExecuteCommand(string command); + public void ExecuteCommand( string command ); /// /// Executes the specified command string in the current context. /// /// The command to execute. Cannot be null or empty. /// The callback to receive the output of the command. - void ExecuteCommandWithBuffer(string command, Action bufferCallback); + public void ExecuteCommandWithBuffer( string command, Action bufferCallback ); /// /// The time since the server started. /// [Obsolete("Use GlobalVars.CurrentTime instead.")] - float CurrentTime { get; } + public float CurrentTime { get; } /// /// The number of simulation ticks that have occurred since the server started. /// [Obsolete("Use GlobalVars.TickCount instead.")] - int TickCount { get; } + public int TickCount { get; } /// /// Find a game system by name. /// /// The name of the game system. /// The game system handle. Null if not found. - nint? FindGameSystemByName(string name); + public nint? FindGameSystemByName( string name ); + + /// + /// Dispatches a particle effect to the specified recipients. + /// + /// The name of the particle effect. + /// The type of attachment for the particle effect. + /// The attachment point for the particle effect. + /// The name of the attachment for the particle effect. + /// The recipient filter for the particle effect. + /// Whether to reset all particles on the entity. + /// The split screen slot for the particle effect. + /// The entity to attach the particle effect to. + public void DispatchParticleEffect( string particleName, ParticleAttachment_t attachmentType, byte attachmentPoint, CUtlSymbolLarge attachmentName, CRecipientFilter filter, bool resetAllParticlesOnEntity = false, int splitScreenSlot = 0, CBaseEntity? entity = null ); } \ No newline at end of file diff --git a/managed/src/SwiftlyS2.Shared/Modules/Events/EventDelegates.cs b/managed/src/SwiftlyS2.Shared/Modules/Events/EventDelegates.cs index 8f6de8d08..94f4ae618 100644 --- a/managed/src/SwiftlyS2.Shared/Modules/Events/EventDelegates.cs +++ b/managed/src/SwiftlyS2.Shared/Modules/Events/EventDelegates.cs @@ -103,10 +103,23 @@ public class EventDelegates /// public delegate void OnPrecacheResource(IOnPrecacheResourceEvent @event); + [Obsolete("OnEntityTouchHook is deprecated. Use OnEntityStartTouch, OnEntityTouch, or OnEntityEndTouch instead.")] + public delegate void OnEntityTouchHook(IOnEntityTouchHookEvent @event); + + /// + /// Called when an entity starts touching another entity. + /// + public delegate void OnEntityStartTouch(IOnEntityStartTouchEvent @event); + /// /// Called when an entity is touching another entity. /// - public delegate void OnEntityTouchHook(IOnEntityTouchHookEvent @event); + public delegate void OnEntityTouch(IOnEntityTouchEvent @event); + + /// + /// Called when an entity ends touching another entity. + /// + public delegate void OnEntityEndTouch(IOnEntityEndTouchEvent @event); /// /// Called when an item services can acquire hook is triggered. diff --git a/managed/src/SwiftlyS2.Shared/Modules/Events/EventParams/IOnEntityTouchHookEvent.cs b/managed/src/SwiftlyS2.Shared/Modules/Events/EventParams/IOnEntityTouchHookEvent.cs index e10759582..7891a0e87 100644 --- a/managed/src/SwiftlyS2.Shared/Modules/Events/EventParams/IOnEntityTouchHookEvent.cs +++ b/managed/src/SwiftlyS2.Shared/Modules/Events/EventParams/IOnEntityTouchHookEvent.cs @@ -1,5 +1,6 @@ namespace SwiftlyS2.Shared.SchemaDefinitions { + [Obsolete("EntityTouchType is deprecated. Use separate OnEntityStartTouch, OnEntityTouch, and OnEntityEndTouch events instead.")] public enum EntityTouchType : byte { StartTouch = 0, @@ -12,10 +13,7 @@ namespace SwiftlyS2.Shared.Events { using SwiftlyS2.Shared.SchemaDefinitions; - /// - /// Called when an entity touches another entity. - /// This event is triggered for StartTouch, Touch, and EndTouch interactions. - /// + [Obsolete("IOnEntityTouchHookEvent is deprecated. Use IOnEntityStartTouchEvent, IOnEntityTouchEvent, or IOnEntityEndTouchEvent instead.")] public interface IOnEntityTouchHookEvent { @@ -34,4 +32,55 @@ public interface IOnEntityTouchHookEvent /// public EntityTouchType TouchType { get; } } + + /// + /// Called when an entity starts touching another entity. + /// + public interface IOnEntityStartTouchEvent + { + + /// + /// Gets the entity that initiated the touch. + /// + public CBaseEntity Entity { get; } + + /// + /// Gets the entity being touched. + /// + public CBaseEntity OtherEntity { get; } + } + + /// + /// Called when an entity is touching another entity. + /// + public interface IOnEntityTouchEvent + { + + /// + /// Gets the entity that initiated the touch. + /// + public CBaseEntity Entity { get; } + + /// + /// Gets the entity being touched. + /// + public CBaseEntity OtherEntity { get; } + } + + /// + /// Called when an entity ends touching another entity. + /// + public interface IOnEntityEndTouchEvent + { + + /// + /// Gets the entity that initiated the touch. + /// + public CBaseEntity Entity { get; } + + /// + /// Gets the entity being touched. + /// + public CBaseEntity OtherEntity { get; } + } } \ No newline at end of file diff --git a/managed/src/SwiftlyS2.Shared/Modules/Events/IEventSubscriber.cs b/managed/src/SwiftlyS2.Shared/Modules/Events/IEventSubscriber.cs index a7671a5fb..ba694fb22 100644 --- a/managed/src/SwiftlyS2.Shared/Modules/Events/IEventSubscriber.cs +++ b/managed/src/SwiftlyS2.Shared/Modules/Events/IEventSubscriber.cs @@ -123,8 +123,21 @@ public interface IEventSubscriber /// public event EventDelegates.OnCommandExecuteHook? OnCommandExecuteHook; + [Obsolete("OnEntityTouchHook is deprecated. Use OnEntityStartTouch, OnEntityTouch, or OnEntityEndTouch instead.")] + public event EventDelegates.OnEntityTouchHook? OnEntityTouchHook; + + /// + /// Called when an entity starts touching another entity. + /// + public event EventDelegates.OnEntityStartTouch? OnEntityStartTouch; + /// /// Called when an entity is touching another entity. /// - public event EventDelegates.OnEntityTouchHook? OnEntityTouchHook; + public event EventDelegates.OnEntityTouch? OnEntityTouch; + + /// + /// Called when an entity ends touching another entity. + /// + public event EventDelegates.OnEntityEndTouch? OnEntityEndTouch; } \ No newline at end of file diff --git a/managed/src/SwiftlyS2.Shared/Modules/Menus/IMenu.cs b/managed/src/SwiftlyS2.Shared/Modules/Menus/IMenu.cs index f63cfdde5..12e6f4068 100644 --- a/managed/src/SwiftlyS2.Shared/Modules/Menus/IMenu.cs +++ b/managed/src/SwiftlyS2.Shared/Modules/Menus/IMenu.cs @@ -175,7 +175,8 @@ public interface IMenu /// Updates the menu display with current state and options. /// /// The player to re-render the menu for. - public void Rerender(IPlayer player); + /// True to update display text, false to render without updating display text. + public void Rerender(IPlayer player, bool updateDisplayText = false); /// /// Determines whether the currently selected option is selectable for the specified player. @@ -198,4 +199,186 @@ public interface IMenu /// The player to set the freeze state for. /// True to freeze the player, false to unfreeze. public void SetFreezeState(IPlayer player, bool freeze); + + /// + /// Gets or sets the vertical scroll style for the menu navigation. + /// Determines how the selection arrow moves when navigating through options. + /// + public MenuVerticalScrollStyle VerticalScrollStyle { get; set; } + + /// + /// Gets or sets the horizontal text display style for menu options. + /// Controls maximum text width and overflow behavior. Null means no horizontal restrictions. + /// + public MenuHorizontalStyle? HorizontalStyle { get; set; } +} + +/// +/// Defines the vertical scroll behavior style for menu navigation. +/// +public enum MenuVerticalScrollStyle +{ + /// + /// Linear vertical scrolling mode where the selection indicator moves within the visible area. + /// Content displays linearly without wrapping, indicator adjusts position as selection changes. + /// + LinearScroll, + + /// + /// Attempts to always keep the selection indicator at the preset center position. + /// Content scrolls vertically in a circular manner around the center, allowing wrap-around display (e.g., 7 8 1 2 3). + /// + CenterFixed, + + /// + /// Waits for the selection indicator to reach the preset center, then maintains it there. + /// Indicator adjusts position at the edges but stays centered during mid-range vertical navigation. + /// + WaitingCenter } + +/// +/// Defines the horizontal text overflow behavior for menu options. +/// +public enum MenuHorizontalOverflowStyle +{ + /// + /// Truncates text at the end when it exceeds the maximum width, keeping the start portion. + /// Example: "Very Long Text Item" becomes "Very Long..." + /// + TruncateEnd, + + /// + /// Truncates text from both ends when it exceeds the maximum width, keeping the middle portion. + /// Example: "Very Long Text Item" becomes "Long Text" + /// + TruncateBothEnds, + + /// + /// Scrolls text to the left with fade-out effect. + /// Text scrolls left and gradually fades out at the left edge. + /// + ScrollLeftFade, + + /// + /// Scrolls text to the right with fade-out effect. + /// Text scrolls right and gradually fades out at the right edge. + /// + ScrollRightFade, + + /// + /// Scrolls text to the left in a continuous loop. + /// Text exits from the left edge and re-enters from the right edge. + /// + ScrollLeftLoop, + + /// + /// Scrolls text to the right in a continuous loop. + /// Text exits from the right edge and re-enters from the left edge. + /// + ScrollRightLoop +} + +/// +/// Horizontal text display style configuration for menu options. +/// +public readonly record struct MenuHorizontalStyle +{ + private readonly float maxWidth; + + /// + /// The maximum display width for menu option text in relative units. + /// + public required float MaxWidth + { + get => maxWidth; + init + { + if (value < 1f) + { + Spectre.Console.AnsiConsole.WriteException(new ArgumentOutOfRangeException(nameof(MaxWidth), $"MaxWidth: value {value:F3} is out of range.")); + maxWidth = 1f; + } + else + { + maxWidth = value; + } + } + } + + /// + /// The overflow behavior to apply when text exceeds MaxWidth. + /// + public MenuHorizontalOverflowStyle OverflowStyle { get; init; } + + /// + /// Number of ticks before scrolling by one character. + /// + public int TicksPerScroll { get; init; } + + /// + /// Number of ticks to pause after completing one scroll loop. + /// + public int PauseTicks { get; init; } + + public MenuHorizontalStyle() + { + OverflowStyle = MenuHorizontalOverflowStyle.TruncateEnd; + TicksPerScroll = 16; + PauseTicks = 0; + } + + /// + /// Creates a horizontal style with default behavior. + /// + public static MenuHorizontalStyle Default => + new() { MaxWidth = 26, OverflowStyle = MenuHorizontalOverflowStyle.TruncateEnd }; + + /// + /// Creates a horizontal style with truncate end behavior. + /// + public static MenuHorizontalStyle TruncateEnd(float maxWidth) => + new() { MaxWidth = maxWidth, OverflowStyle = MenuHorizontalOverflowStyle.TruncateEnd }; + + /// + /// Creates a horizontal style with truncate both ends behavior. + /// + public static MenuHorizontalStyle TruncateBothEnds(float maxWidth) => + new() { MaxWidth = maxWidth, OverflowStyle = MenuHorizontalOverflowStyle.TruncateBothEnds }; + + /// + /// Creates a horizontal style with scroll left fade behavior. + /// + /// Maximum display width for text. + /// Number of ticks before scrolling by one character. + /// Number of ticks to pause after completing one scroll loop. + public static MenuHorizontalStyle ScrollLeftFade(float maxWidth, int ticksPerScroll = 16, int pauseTicks = 0) => + new() { MaxWidth = maxWidth, OverflowStyle = MenuHorizontalOverflowStyle.ScrollLeftFade, TicksPerScroll = ticksPerScroll, PauseTicks = pauseTicks }; + + /// + /// Creates a horizontal style with scroll right fade behavior. + /// + /// Maximum display width for text. + /// Number of ticks before scrolling by one character. + /// Number of ticks to pause after completing one scroll loop. + public static MenuHorizontalStyle ScrollRightFade(float maxWidth, int ticksPerScroll = 16, int pauseTicks = 0) => + new() { MaxWidth = maxWidth, OverflowStyle = MenuHorizontalOverflowStyle.ScrollRightFade, TicksPerScroll = ticksPerScroll, PauseTicks = pauseTicks }; + + /// + /// Creates a horizontal style with scroll left loop behavior. + /// + /// Maximum display width for text. + /// Number of ticks before scrolling by one character. + /// Number of ticks to pause after completing one scroll loop. + public static MenuHorizontalStyle ScrollLeftLoop(float maxWidth, int ticksPerScroll = 16, int pauseTicks = 0) => + new() { MaxWidth = maxWidth, OverflowStyle = MenuHorizontalOverflowStyle.ScrollLeftLoop, TicksPerScroll = ticksPerScroll, PauseTicks = pauseTicks }; + + /// + /// Creates a horizontal style with scroll right loop behavior. + /// + /// Maximum display width for text. + /// Number of ticks before scrolling by one character. + /// Number of ticks to pause after completing one scroll loop. + public static MenuHorizontalStyle ScrollRightLoop(float maxWidth, int ticksPerScroll = 16, int pauseTicks = 0) => + new() { MaxWidth = maxWidth, OverflowStyle = MenuHorizontalOverflowStyle.ScrollRightLoop, TicksPerScroll = ticksPerScroll, PauseTicks = pauseTicks }; +} \ No newline at end of file diff --git a/managed/src/SwiftlyS2.Shared/Modules/Menus/IMenuBuilder.cs b/managed/src/SwiftlyS2.Shared/Modules/Menus/IMenuBuilder.cs index 997a1b000..b590d8c89 100644 --- a/managed/src/SwiftlyS2.Shared/Modules/Menus/IMenuBuilder.cs +++ b/managed/src/SwiftlyS2.Shared/Modules/Menus/IMenuBuilder.cs @@ -9,6 +9,8 @@ namespace SwiftlyS2.Shared.Menus; /// public interface IMenuBuilder { + IMenuDesign Design { get; } + /// /// Sets the menu instance that this builder will modify. /// This method is typically called internally to associate the builder with a specific menu. @@ -31,8 +33,9 @@ public interface IMenuBuilder /// The display text for the button. /// Optional action to execute when the button is clicked. Receives the player as parameter. /// The text size for the button display. Defaults to Medium. + /// The overflow style for the text. Defaults to null. /// The current menu builder instance for method chaining. - IMenuBuilder AddButton(string text, Action? onClick = null, IMenuTextSize size = IMenuTextSize.Medium); + IMenuBuilder AddButton(string text, Action? onClick = null, IMenuTextSize size = IMenuTextSize.Medium, MenuHorizontalStyle? overflowStyle = null); /// /// Adds a clickable button option to the menu. @@ -41,8 +44,9 @@ public interface IMenuBuilder /// The display text for the button. /// Optional action to execute when the button is clicked. Receives the player and option as parameters. /// The text size for the button display. Defaults to Medium. + /// The overflow style for the text. Defaults to null. /// The current menu builder instance for method chaining. - IMenuBuilder AddButton(string text, Action? onClick, IMenuTextSize size = IMenuTextSize.Medium); + IMenuBuilder AddButton(string text, Action? onClick, IMenuTextSize size = IMenuTextSize.Medium, MenuHorizontalStyle? overflowStyle = null); /// /// Adds a toggle switch option to the menu that can be turned on or off. @@ -52,8 +56,9 @@ public interface IMenuBuilder /// The initial state of the toggle. Defaults to false. /// Optional action to execute when the toggle state changes. Receives the player and new boolean value. /// The text size for the toggle display. Defaults to Medium. + /// The overflow style for the text. Defaults to null. /// The current menu builder instance for method chaining. - IMenuBuilder AddToggle(string text, bool defaultValue = false, Action? onToggle = null, IMenuTextSize size = IMenuTextSize.Medium); + IMenuBuilder AddToggle(string text, bool defaultValue = false, Action? onToggle = null, IMenuTextSize size = IMenuTextSize.Medium, MenuHorizontalStyle? overflowStyle = null); /// /// Adds a toggle switch option to the menu that can be turned on or off. @@ -63,8 +68,9 @@ public interface IMenuBuilder /// The initial state of the toggle. Defaults to false. /// Optional action to execute when the toggle state changes. Receives the player, option, and new boolean value. /// The text size for the toggle display. Defaults to Medium. + /// The overflow style for the text. Defaults to null. /// The current menu builder instance for method chaining. - IMenuBuilder AddToggle(string text, bool defaultValue, Action? onToggle, IMenuTextSize size = IMenuTextSize.Medium); + IMenuBuilder AddToggle(string text, bool defaultValue, Action? onToggle, IMenuTextSize size = IMenuTextSize.Medium, MenuHorizontalStyle? overflowStyle = null); /// /// Adds a slider option to the menu for selecting numeric values within a specified range. @@ -77,8 +83,9 @@ public interface IMenuBuilder /// The increment/decrement step size. Defaults to 1. /// Optional action to execute when the slider value changes. Receives the player and new float value. /// The text size for the slider display. Defaults to Medium. + /// The overflow style for the text. Defaults to null. /// The current menu builder instance for method chaining. - IMenuBuilder AddSlider(string text, float min, float max, float defaultValue, float step = 1, Action? onChange = null, IMenuTextSize size = IMenuTextSize.Medium); + IMenuBuilder AddSlider(string text, float min, float max, float defaultValue, float step = 1, Action? onChange = null, IMenuTextSize size = IMenuTextSize.Medium, MenuHorizontalStyle? overflowStyle = null); /// /// Adds a slider option to the menu for selecting numeric values within a specified range. @@ -91,8 +98,9 @@ public interface IMenuBuilder /// The increment/decrement step size. /// Optional action to execute when the slider value changes. Receives the player, option, and new float value. /// The text size for the slider display. Defaults to Medium. + /// The overflow style for the text. Defaults to null. /// The current menu builder instance for method chaining. - IMenuBuilder AddSlider(string text, float min, float max, float defaultValue, float step, Action? onChange, IMenuTextSize size = IMenuTextSize.Medium); + IMenuBuilder AddSlider(string text, float min, float max, float defaultValue, float step, Action? onChange, IMenuTextSize size = IMenuTextSize.Medium, MenuHorizontalStyle? overflowStyle = null); /// /// Adds an asynchronous button option to the menu that executes async operations. @@ -101,8 +109,9 @@ public interface IMenuBuilder /// The display text for the async button. /// The async function to execute when the button is clicked. Receives the player as parameter. /// The text size for the button display. Defaults to Medium. + /// The overflow style for the text. Defaults to null. /// The current menu builder instance for method chaining. - IMenuBuilder AddAsyncButton(string text, Func onClickAsync, IMenuTextSize size = IMenuTextSize.Medium); + IMenuBuilder AddAsyncButton(string text, Func onClickAsync, IMenuTextSize size = IMenuTextSize.Medium, MenuHorizontalStyle? overflowStyle = null); /// /// Adds an asynchronous button option to the menu that executes async operations. @@ -111,8 +120,9 @@ public interface IMenuBuilder /// The display text for the async button. /// The async function to execute when the button is clicked. Receives the player and option as parameters. /// The text size for the button display. Defaults to Medium. + /// The overflow style for the text. Defaults to null. /// The current menu builder instance for method chaining. - IMenuBuilder AddAsyncButton(string text, Func onClickAsync, IMenuTextSize size = IMenuTextSize.Medium); + IMenuBuilder AddAsyncButton(string text, Func onClickAsync, IMenuTextSize size = IMenuTextSize.Medium, MenuHorizontalStyle? overflowStyle = null); /// /// Adds a non-interactive text display option to the menu. @@ -121,8 +131,9 @@ public interface IMenuBuilder /// The text content to display. /// The text alignment within the menu. Defaults to Left. /// The text size for the display. Defaults to Medium. + /// The overflow style for the text. Defaults to null. /// The current menu builder instance for method chaining. - IMenuBuilder AddText(string text, ITextAlign alignment = ITextAlign.Left, IMenuTextSize size = IMenuTextSize.Medium); + IMenuBuilder AddText(string text, ITextAlign alignment = ITextAlign.Left, IMenuTextSize size = IMenuTextSize.Medium, MenuHorizontalStyle? overflowStyle = null); /// /// Adds a submenu option that navigates to another menu when selected. @@ -171,19 +182,22 @@ public interface IMenuBuilder /// The initially selected choice. Defaults to null (first choice). /// Optional action to execute when the choice changes. Receives the player and selected choice string. /// The text size for the choice display. Defaults to Medium. + /// The overflow style for the text. Defaults to null. /// The current menu builder instance for method chaining. - IMenuBuilder AddChoice(string text, string[] choices, string? defaultChoice = null, Action? onChange = null, IMenuTextSize size = IMenuTextSize.Medium); + IMenuBuilder AddChoice(string text, string[] choices, string? defaultChoice = null, Action? onChange = null, IMenuTextSize size = IMenuTextSize.Medium, MenuHorizontalStyle? overflowStyle = null); - /// - /// Adds a choice selection option that allows players to select from multiple predefined options. - /// Players can cycle through the available choices using left/right navigation. - /// - /// The display text for the choice option. - /// An array of available choice strings. - /// The initially selected choice. - /// Optional action to execute when the choice changes. Receives the player and selected choice string. - /// The current menu builder instance for method chaining. - IMenuBuilder AddChoice(string text, string[] choices, string? defaultChoice, Action? onChange); + // /// + // /// Adds a choice selection option that allows players to select from multiple predefined options. + // /// Players can cycle through the available choices using left/right navigation. + // /// + // /// The display text for the choice option. + // /// An array of available choice strings. + // /// The initially selected choice. + // /// Optional action to execute when the choice changes. Receives the player and selected choice string. + // /// The overflow style for the text. Defaults to null. + // /// The current menu builder instance for method chaining. + // [Obsolete("This overload causes ambiguity. Use AddChoice(text, choices, defaultChoice, onChange, IMenuTextSize.Medium, overflowStyle) instead.")] + // IMenuBuilder AddChoice(string text, string[] choices, string? defaultChoice, Action? onChange, MenuHorizontalStyle? overflowStyle = null); /// /// Adds a choice selection option that allows players to select from multiple predefined options. @@ -194,8 +208,9 @@ public interface IMenuBuilder /// The initially selected choice. /// Optional action to execute when the choice changes. Receives the player, option, and selected choice string. /// The text size for the choice display. Defaults to Medium. + /// The overflow style for the text. Defaults to null. /// The current menu builder instance for method chaining. - IMenuBuilder AddChoice(string text, string[] choices, string? defaultChoice, Action? onChange, IMenuTextSize size = IMenuTextSize.Medium); + IMenuBuilder AddChoice(string text, string[] choices, string? defaultChoice, Action? onChange, IMenuTextSize size = IMenuTextSize.Medium, MenuHorizontalStyle? overflowStyle = null); /// /// Adds a visual separator line to the menu for organizing content. @@ -212,8 +227,9 @@ public interface IMenuBuilder /// A function that returns the current progress value (0.0 to 1.0). /// The character width of the progress bar. Defaults to 20. /// The text size for the progress bar display. Defaults to Medium. + /// The overflow style for the text. Defaults to null. /// The current menu builder instance for method chaining. - IMenuBuilder AddProgressBar(string text, Func progressProvider, int barWidth = 20, IMenuTextSize size = IMenuTextSize.Medium); + IMenuBuilder AddProgressBar(string text, Func progressProvider, int barWidth = 20, IMenuTextSize size = IMenuTextSize.Medium, MenuHorizontalStyle? overflowStyle = null); /// /// Adds a progress bar display option that shows dynamic progress information. @@ -222,8 +238,9 @@ public interface IMenuBuilder /// The display text for the progress bar. /// A function that returns the current progress value (0.0 to 1.0). /// The character width of the progress bar. + /// The overflow style for the text. Defaults to null. /// The current menu builder instance for method chaining. - IMenuBuilder AddProgressBar(string text, Func progressProvider, int barWidth); + IMenuBuilder AddProgressBar(string text, Func progressProvider, int barWidth, MenuHorizontalStyle? overflowStyle = null); /// /// Sets the parent menu for the menu being built, creating a hierarchical menu structure. @@ -270,6 +287,7 @@ public interface IMenuBuilder /// /// The names of the buttons to use for selection. /// The current menu builder instance for method chaining. + [Obsolete("Use Design.OverrideSelectButton instead")] IMenuBuilder OverrideSelectButton(params string[] buttonNames); /// @@ -278,6 +296,7 @@ public interface IMenuBuilder /// /// The names of the buttons to use for movement. /// The current menu builder instance for method chaining. + [Obsolete("Use Design.OverrideMoveButton instead")] IMenuBuilder OverrideMoveButton(params string[] buttonNames); /// @@ -286,6 +305,7 @@ public interface IMenuBuilder /// /// The names of the buttons to use for exiting. /// The current menu builder instance for method chaining. + [Obsolete("Use Design.OverrideExitButton instead")] IMenuBuilder OverrideExitButton(params string[] buttonNames); /// @@ -294,6 +314,12 @@ public interface IMenuBuilder /// /// The maximum number of visible items. /// The current menu builder instance for method chaining. + /// + /// If the provided count is less than 1, it will be clamped to 1. + /// If the provided count is greater than 5, it will be clamped to 5. + /// A warning will be logged when clamping occurs. + /// + [Obsolete("Use Design.MaxVisibleItems instead")] IMenuBuilder MaxVisibleItems(int count); /// @@ -316,6 +342,7 @@ public interface IMenuBuilder /// /// The color to use for menu rendering. /// The current menu builder instance for method chaining. + [Obsolete("Use Design.SetColor instead")] IMenuBuilder SetColor(Color color); } diff --git a/managed/src/SwiftlyS2.Shared/Modules/Menus/IMenuDesign.cs b/managed/src/SwiftlyS2.Shared/Modules/Menus/IMenuDesign.cs new file mode 100644 index 000000000..86accb061 --- /dev/null +++ b/managed/src/SwiftlyS2.Shared/Modules/Menus/IMenuDesign.cs @@ -0,0 +1,66 @@ +using SwiftlyS2.Shared.Natives; + +namespace SwiftlyS2.Shared.Menus; + +public interface IMenuDesign +{ + /// + /// Overrides the default button(s) used for selecting menu options. + /// Allows customization of the input controls for menu interaction. + /// + /// The names of the buttons to use for selection. + /// The current menu design instance for method chaining. + IMenuDesign OverrideSelectButton(params string[] buttonNames); + + /// + /// Overrides the default button(s) used for moving through menu options. + /// Allows customization of the input controls for menu navigation. + /// + /// The names of the buttons to use for movement. + /// The current menu design instance for method chaining. + IMenuDesign OverrideMoveButton(params string[] buttonNames); + + /// + /// Overrides the default button(s) used for exiting or closing the menu. + /// Allows customization of the input controls for menu exit. + /// + /// The names of the buttons to use for exiting. + /// The current menu design instance for method chaining. + IMenuDesign OverrideExitButton(params string[] buttonNames); + + /// + /// Sets the maximum number of menu items visible at once. + /// When there are more items than this limit, the menu will be paginated. + /// + /// The maximum number of visible items. + /// The current menu design instance for method chaining. + /// + /// If the provided count is less than 1, it will be clamped to 1. + /// If the provided count is greater than 5, it will be clamped to 5. + /// A warning will be logged when clamping occurs. + /// + IMenuDesign MaxVisibleItems(int count); + + /// + /// Sets the color used for rendering the menu. + /// Affects the visual appearance and styling of the menu display. + /// + /// The color to use for menu rendering. + /// The current menu design instance for method chaining. + IMenuDesign SetColor(Color color); + + /// + /// Sets the vertical scroll style for the menu navigation. + /// + /// The vertical scroll style to use. + /// The current menu design instance for method chaining. + IMenuDesign SetVerticalScrollStyle(MenuVerticalScrollStyle style); + + /// + /// Sets the global horizontal style for menu option text display. + /// Controls maximum text width and overflow behavior for all menu options. + /// + /// The global horizontal style to apply. + /// The current menu design instance for method chaining. + IMenuDesign SetGlobalHorizontalStyle(MenuHorizontalStyle style); +} \ No newline at end of file diff --git a/managed/src/SwiftlyS2.Shared/Modules/Menus/IMenuManager.cs b/managed/src/SwiftlyS2.Shared/Modules/Menus/IMenuManager.cs index 1e28636c6..541b5a53f 100644 --- a/managed/src/SwiftlyS2.Shared/Modules/Menus/IMenuManager.cs +++ b/managed/src/SwiftlyS2.Shared/Modules/Menus/IMenuManager.cs @@ -110,6 +110,12 @@ public interface IMenuManager /// The player's current menu, or null if no menu is open. public IMenu? GetMenu(IPlayer player); + /// + /// Closes all open menus for all players. + /// This includes all menus in the parent chain. + /// + public void CloseAllMenus(); + /// /// Closes the specified menu for all players who currently have it open. /// This will trigger the OnClose event for each affected player. diff --git a/managed/src/SwiftlyS2.Shared/Modules/Menus/IOption.cs b/managed/src/SwiftlyS2.Shared/Modules/Menus/IOption.cs index c44e65cf1..87d979bdd 100644 --- a/managed/src/SwiftlyS2.Shared/Modules/Menus/IOption.cs +++ b/managed/src/SwiftlyS2.Shared/Modules/Menus/IOption.cs @@ -27,6 +27,11 @@ public interface IOption /// public IMenu? Menu { get; set; } + /// + /// Gets the horizontal overflow style for this option's text display. + /// + public MenuHorizontalStyle? OverflowStyle { get; } + /// /// Determines whether this option should be shown to the specified player. /// diff --git a/managed/src/SwiftlyS2.Shared/Modules/Players/IPlayer.cs b/managed/src/SwiftlyS2.Shared/Modules/Players/IPlayer.cs index be4d8c271..a7644ff88 100644 --- a/managed/src/SwiftlyS2.Shared/Modules/Players/IPlayer.cs +++ b/managed/src/SwiftlyS2.Shared/Modules/Players/IPlayer.cs @@ -38,12 +38,18 @@ public interface IPlayer : IEquatable /// Gets the unique identifier for the player. /// public int PlayerID { get; } + + /// + /// Gets the slot of the player. Equals to the player ID. + /// + public int Slot { get; } + /// /// Sends a message of the specified type to the player. /// /// The type of message to send. Determines how the message is processed or displayed. /// The content of the message to send. Cannot be null. - void SendMessage(MessageType kind, string message); + public void SendMessage(MessageType kind, string message); /// /// Whether the client is a bot. /// diff --git a/managed/src/SwiftlyS2.Shared/Modules/Players/IPlayerManager.cs b/managed/src/SwiftlyS2.Shared/Modules/Players/IPlayerManager.cs index 17c5b49f0..3fdc7c9ed 100644 --- a/managed/src/SwiftlyS2.Shared/Modules/Players/IPlayerManager.cs +++ b/managed/src/SwiftlyS2.Shared/Modules/Players/IPlayerManager.cs @@ -6,7 +6,7 @@ public interface IPlayerManagerService /// Checks whether a specific player is currently online and connected to the server. /// /// True if the player is online, false otherwise. - bool IsPlayerOnline(int playerid); + public bool IsPlayerOnline(int playerid); /// /// Gets the number of players currently in the game. @@ -23,17 +23,17 @@ public interface IPlayerManagerService /// /// The type of message display. /// The text content to send to players. - void SendMessage(MessageType kind, string message); + public void SendMessage(MessageType kind, string message); /// /// Controls whether a specific entity should be blocked from being transmitted/synchronized to clients. /// - void ShouldBlockTransmitEntity(int entityid, bool shouldBlockTransmit); + public void ShouldBlockTransmitEntity(int entityid, bool shouldBlockTransmit); /// /// Removes all entity transmission blocks, allowing all previously blocked entities to be transmitted to clients again. /// - void ClearAllBlockedTransmitEntities(); + public void ClearAllBlockedTransmitEntities(); /// /// Retrieves the player associated with the specified player ID. @@ -41,13 +41,13 @@ public interface IPlayerManagerService /// The unique identifier of the player to retrieve. Must be a valid player ID. /// An instance representing the player with the specified ID, or null if no such /// player exists. - IPlayer GetPlayer(int playerid); + public IPlayer GetPlayer(int playerid); /// /// Retrieves all players currently online. /// /// An enumerable collection of instances representing all online players. - IEnumerable GetAllPlayers(); + public IEnumerable GetAllPlayers(); /// /// Finds targetted players based on the provided search criteria. @@ -56,5 +56,5 @@ public interface IPlayerManagerService /// The target player name or identifier. /// The search mode to apply. /// A collection of players matching the search criteria. - IEnumerable FindTargettedPlayers(IPlayer player, string target, TargetSearchMode searchMode); + public IEnumerable FindTargettedPlayers(IPlayer player, string target, TargetSearchMode searchMode); } \ No newline at end of file diff --git a/managed/src/SwiftlyS2.Shared/Natives/Structs/CRecipientFilter.cs b/managed/src/SwiftlyS2.Shared/Natives/Structs/CRecipientFilter.cs index 06a7c3b14..c9aaf8d25 100644 --- a/managed/src/SwiftlyS2.Shared/Natives/Structs/CRecipientFilter.cs +++ b/managed/src/SwiftlyS2.Shared/Natives/Structs/CRecipientFilter.cs @@ -1,8 +1,9 @@ using System.Runtime.InteropServices; +using SwiftlyS2.Core.Natives; namespace SwiftlyS2.Shared.Natives; -public enum NetChannelBufType_t: sbyte +public enum NetChannelBufType_t : sbyte { BUF_DEFAULT = -1, BUF_UNRELIABLE = 0, @@ -18,7 +19,7 @@ public struct CRecipientFilter public NetChannelBufType_t BufferType; public bool InitMessage; - public CRecipientFilter(NetChannelBufType_t BufType = NetChannelBufType_t.BUF_RELIABLE, bool bInitMessage = false) + public CRecipientFilter( NetChannelBufType_t BufType = NetChannelBufType_t.BUF_RELIABLE, bool bInitMessage = false ) { _pVTable = CRecipientFilterVtable.pCRecipientFilterVTable; RecipientsMask = 0; @@ -26,23 +27,24 @@ public CRecipientFilter(NetChannelBufType_t BufType = NetChannelBufType_t.BUF_RE BufferType = BufType; } - public static CRecipientFilter FromMask(ulong playerMask) + public static CRecipientFilter FromMask( ulong playerMask ) { CRecipientFilter filter = new(); filter.RecipientsMask = playerMask; return filter; } - public static CRecipientFilter FromPlayers(params int[] players) + public static CRecipientFilter FromPlayers( params int[] players ) { CRecipientFilter filter = new(); - foreach (int player in players) { + foreach (var player in players) + { filter.AddRecipient(player); } return filter; } - public static CRecipientFilter FromSingle(int player) + public static CRecipientFilter FromSingle( int player ) { CRecipientFilter filter = new(); filter.AddRecipient(player); @@ -56,8 +58,13 @@ public ulong ToMask() public void AddAllPlayers() { - RecipientsMask = 0xFFFFFFFFFFFFFFFF; - // @todo: When playermanager will be implemeneted, iterate through all the 64 players and if they're online and are authorized, add them here + for (var i = 0; i < NativePlayerManager.GetPlayerCap(); i++) + { + if (NativePlayerManager.IsPlayerOnline(i)) + { + AddRecipient(i); + } + } } public void RemoveAllPlayers() @@ -65,14 +72,14 @@ public void RemoveAllPlayers() RecipientsMask = 0; } - public void AddRecipient(int playerid) + public void AddRecipient( int playerid ) { if (playerid < 0 || playerid > 63) throw new IndexOutOfRangeException("PlayerID out of range (0-63)."); RecipientsMask |= 1UL << playerid; } - public void RemoveRecipient(int playerid) + public void RemoveRecipient( int playerid ) { if (playerid < 0 || playerid > 63) throw new IndexOutOfRangeException("PlayerID out of range (0-63)."); @@ -82,8 +89,10 @@ public void RemoveRecipient(int playerid) public int GetRecipientCount() { int count = 0; - for (int i = 0; i < 64; i++) { - if ((RecipientsMask & (1UL << i)) != 0) { + for (int i = 0; i < 64; i++) + { + if ((RecipientsMask & (1UL << i)) != 0) + { count++; } } @@ -91,36 +100,42 @@ public int GetRecipientCount() } } -internal static class CRecipientFilterVtable { +internal static class CRecipientFilterVtable +{ public static nint pCRecipientFilterVTable; [UnmanagedCallersOnly] - public unsafe static void Destructor(CRecipientFilter* filter) { + public unsafe static void Destructor( CRecipientFilter* filter ) + { // do nothing } [UnmanagedCallersOnly] - public unsafe static NetChannelBufType_t GetNetworkBufType(CRecipientFilter* filter) { + public unsafe static NetChannelBufType_t GetNetworkBufType( CRecipientFilter* filter ) + { return filter->BufferType; } [UnmanagedCallersOnly] - public unsafe static bool IsInitMessage(CRecipientFilter* filter) { + public unsafe static bool IsInitMessage( CRecipientFilter* filter ) + { return filter->InitMessage; } [UnmanagedCallersOnly] - public unsafe static ulong* GetRecipients(CRecipientFilter* filter) { + public unsafe static ulong* GetRecipients( CRecipientFilter* filter ) + { return &filter->RecipientsMask; } - static unsafe CRecipientFilterVtable() { + static unsafe CRecipientFilterVtable() + { pCRecipientFilterVTable = Marshal.AllocHGlobal(sizeof(nint) * 4); Span vtable = new((void*)pCRecipientFilterVTable, 4); - vtable[0] = (nint)(delegate* unmanaged)(&Destructor); - vtable[1] = (nint)(delegate* unmanaged)(&GetNetworkBufType); - vtable[2] = (nint)(delegate* unmanaged)(&IsInitMessage); - vtable[3] = (nint)(delegate* unmanaged)(&GetRecipients); + vtable[0] = (nint)(delegate* unmanaged< CRecipientFilter*, void >)(&Destructor); + vtable[1] = (nint)(delegate* unmanaged< CRecipientFilter*, NetChannelBufType_t >)(&GetNetworkBufType); + vtable[2] = (nint)(delegate* unmanaged< CRecipientFilter*, bool >)(&IsInitMessage); + vtable[3] = (nint)(delegate* unmanaged< CRecipientFilter*, ulong* >)(&GetRecipients); } } \ No newline at end of file diff --git a/managed/src/SwiftlyS2.Shared/Services/IPluginConfigurationService.cs b/managed/src/SwiftlyS2.Shared/Services/IPluginConfigurationService.cs index 90d4c3ab3..a1cdc66f9 100644 --- a/managed/src/SwiftlyS2.Shared/Services/IPluginConfigurationService.cs +++ b/managed/src/SwiftlyS2.Shared/Services/IPluginConfigurationService.cs @@ -2,7 +2,8 @@ namespace SwiftlyS2.Shared.Services; -public interface IPluginConfigurationService { +public interface IPluginConfigurationService +{ /// /// Get the base path of plugin configuration. @@ -33,7 +34,15 @@ public interface IPluginConfigurationService { /// The name of the configuration file. /// The name of the section in the configuration file. public IPluginConfigurationService InitializeJsonWithModel(string name, string sectionName) where T : class, new(); - + + /// + /// Initialize the TOML configuration file with a class as template. + /// + /// The type of the configuration model. + /// The name of the configuration file. + /// The name of the section in the configuration file. + public IPluginConfigurationService InitializeTomlWithModel(string name, string sectionName) where T : class, new(); + /// /// Configure the internal configuration manager. /// @@ -52,4 +61,4 @@ public interface IPluginConfigurationService { /// public bool BasePathExists { get; } -} \ No newline at end of file +} diff --git a/managed/src/TestPlugin/TestPlugin.cs b/managed/src/TestPlugin/TestPlugin.cs index 592b964a5..4895f243f 100644 --- a/managed/src/TestPlugin/TestPlugin.cs +++ b/managed/src/TestPlugin/TestPlugin.cs @@ -28,6 +28,7 @@ using BenchmarkDotNet.Toolchains.InProcess.Emit; using BenchmarkDotNet.Jobs; using BenchmarkDotNet.Loggers; +using SwiftlyS2.Shared.Menus; namespace TestPlugin; @@ -110,26 +111,31 @@ public override void Load(bool hotReload) // Core.Logger.LogInformation("CommandExecute: {name} with {args}", @event.Command[0], @event.Command.ArgS); // }; - Core.Event.OnEntityTouchHook += (@event) => - { - switch (@event.TouchType) - { - case EntityTouchType.StartTouch: - Console.WriteLine($"EntityStartTouch: {@event.Entity.Entity?.DesignerName} -> {@event.OtherEntity.Entity?.DesignerName}"); - break; - case EntityTouchType.Touch: - break; - case EntityTouchType.EndTouch: - if (@event.Entity.Entity?.DesignerName != "player" || @event.OtherEntity.Entity?.DesignerName != "player") - { - return; - } - var player = @event.Entity.As(); - var otherPlayer = @event.OtherEntity.As(); - Console.WriteLine($"EntityEndTouch: {(player.Controller.Value?.PlayerName ?? string.Empty)} -> {(otherPlayer.Controller.Value?.PlayerName ?? string.Empty)}"); - break; - } - }; + // Core.Event.OnEntityStartTouch += (@event) => + // { + // Console.WriteLine($"[New] EntityStartTouch: {@event.Entity.Entity?.DesignerName} -> {@event.OtherEntity.Entity?.DesignerName}"); + // }; + + // Core.Event.OnEntityTouchHook += (@event) => + // { + // switch (@event.TouchType) + // { + // case EntityTouchType.StartTouch: + // Console.WriteLine($"EntityStartTouch: {@event.Entity.Entity?.DesignerName} -> {@event.OtherEntity.Entity?.DesignerName}"); + // break; + // case EntityTouchType.Touch: + // break; + // case EntityTouchType.EndTouch: + // if (@event.Entity.Entity?.DesignerName != "player" || @event.OtherEntity.Entity?.DesignerName != "player") + // { + // return; + // } + // var player = @event.Entity.As(); + // var otherPlayer = @event.OtherEntity.As(); + // Console.WriteLine($"EntityEndTouch: {(player.Controller.Value?.PlayerName ?? string.Empty)} -> {(otherPlayer.Controller.Value?.PlayerName ?? string.Empty)}"); + // break; + // } + // }; Core.Engine.ExecuteCommandWithBuffer("@ping", (buffer) => { @@ -548,6 +554,88 @@ public HookResult TestServerNetMessageHandler(CCSUsrMsg_SendPlayerItemDrops msg) return HookResult.Continue; } + [Command("mt")] + public void MenuTestCommand(ICommandContext context) + { + var player = context.Sender!; + + IMenu settingsMenu = Core.Menus.CreateMenu("MenuTest"); + + settingsMenu.Builder.Design.MaxVisibleItems(5); + + // settingsMenu.Builder.Design.MaxVisibleItems(Random.Shared.Next(-2, 8)); + if (context.Args.Length < 1 || !int.TryParse(context.Args[0], out int vtype)) vtype = 0; + settingsMenu.Builder.Design.SetVerticalScrollStyle(vtype switch + { + 1 => MenuVerticalScrollStyle.LinearScroll, + 2 => MenuVerticalScrollStyle.WaitingCenter, + _ => MenuVerticalScrollStyle.CenterFixed + }); + + if (context.Args.Length < 2 || !int.TryParse(context.Args[1], out int htype)) htype = 0; + settingsMenu.Builder.Design.SetGlobalHorizontalStyle(htype switch + { + 0 => MenuHorizontalStyle.Default, + 1 => MenuHorizontalStyle.TruncateBothEnds(26f), + 2 => MenuHorizontalStyle.ScrollLeftFade(26f, 8, 128), + 3 => MenuHorizontalStyle.ScrollLeftLoop(26f, 8, 128), + 1337 => MenuHorizontalStyle.TruncateEnd(0f), + _ => MenuHorizontalStyle.TruncateEnd(26f) + }); + + settingsMenu.Builder.AddButton("1. AButton",(p) => + { + player.SendMessage(MessageType.Chat, "Button"); + }); + + settingsMenu.Builder.AddToggle("2. Toggle", defaultValue: true, (p, value) => + { + player.SendMessage(MessageType.Chat, $"AddToggle {value}"); + }); + + settingsMenu.Builder.AddSlider("3. Slider", min: 0, max: 100, defaultValue: 10, step: 10, (p, value) => + { + player.SendMessage(MessageType.Chat, $"AddSlider {value}"); + }); + + settingsMenu.Builder.AddAsyncButton("4. AsyncButton", async (p) => + { + await Task.Delay(2000); + }); + + settingsMenu.Builder.AddText("5. Text"); + settingsMenu.Builder.AddText("6. Text"); + settingsMenu.Builder.AddText("7. Text"); + settingsMenu.Builder.AddText("8. Text"); + settingsMenu.Builder.AddText("9. Text"); + settingsMenu.Builder.AddSeparator(); + settingsMenu.Builder.AddText($"{HtmlGradient.GenerateGradientText("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ", "#FFE4E1", "#FFC0CB", "#FF69B4")}", overflowStyle: MenuHorizontalStyle.TruncateEnd(26f)); + settingsMenu.Builder.AddText($"{HtmlGradient.GenerateGradientText("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ", "#FFE5CC", "#FFAB91", "#FF7043")}", overflowStyle: MenuHorizontalStyle.TruncateBothEnds(26f)); + settingsMenu.Builder.AddText($"{HtmlGradient.GenerateGradientText("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ", "#E6E6FA", "#00FFFF", "#FF1493")}", overflowStyle: MenuHorizontalStyle.ScrollRightFade(26f, 8)); + settingsMenu.Builder.AddText($"{HtmlGradient.GenerateGradientText("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ", "#AFEEEE", "#7FFFD4", "#40E0D0")}", overflowStyle: MenuHorizontalStyle.ScrollLeftLoop(26f, 8)); + settingsMenu.Builder.AddText("12345678901234567890split12345678901234567890", overflowStyle: MenuHorizontalStyle.ScrollLeftFade(26f, 8, 128)); + settingsMenu.Builder.AddText("一二三四五六七八九十分割一二三四五六七八九十", overflowStyle: MenuHorizontalStyle.ScrollRightLoop(26f, 8, 64)); + settingsMenu.Builder.AddSeparator(); + settingsMenu.Builder.AddText("Swiftlys2 向这广袤世界致以温柔问候", overflowStyle: MenuHorizontalStyle.ScrollRightLoop(26f, 8)); + settingsMenu.Builder.AddText("Swiftlys2 からこの広大なる世界へ温かい挨拶を"); + settingsMenu.Builder.AddText("Swiftlys2 가 이 넓은 세상에 따뜻한 인사를 전합니다"); + settingsMenu.Builder.AddText("Swiftlys2 приветствует этот прекрасный мир"); + settingsMenu.Builder.AddText("Swiftlys2 salută această lume minunată"); + settingsMenu.Builder.AddText("Swiftlys2 extends warmest greetings to this wondrous world"); + settingsMenu.Builder.AddText("Swiftlys2 sendas korajn salutojn al ĉi tiu mirinda mondo"); + settingsMenu.Builder.AddSeparator(); + settingsMenu.Builder.AddAsyncButton("AsyncButton|AsyncButton|AsyncButton", async (p) => await Task.Delay(2000)); + settingsMenu.Builder.AddButton("Button|Button|Button|Button", (p) => { }); + settingsMenu.Builder.AddChoice("Choice|Choice|Choice|Choice", ["Option 1", "Option 2", "Option 3"], "Option 1", (p, value) => { }, overflowStyle: MenuHorizontalStyle.TruncateEnd(8f)); + settingsMenu.Builder.AddProgressBar("ProgressBar|ProgressBar|ProgressBar", () => (float)Random.Shared.NextDouble(), overflowStyle: MenuHorizontalStyle.ScrollLeftLoop(26f, 12)); + settingsMenu.Builder.AddSlider("Slider|Slider|Slider|Slider", 0f, 100f, 0f, 1f, (p, value) => { }, overflowStyle: MenuHorizontalStyle.ScrollRightLoop(8f, 12)); + // settingsMenu.Builder.AddSubmenu("Submenu"); + settingsMenu.Builder.AddToggle("Toggle|Toggle|Toggle|Toggle", true, (p, value) => { }); + settingsMenu.Builder.AddSeparator(); + + Core.Menus.OpenMenu(player, settingsMenu); + } + [Command("menu")] public void MenuCommand(ICommandContext context) { @@ -588,12 +676,13 @@ public void MenuCommand(ICommandContext context) player.SendMessage(MessageType.Chat, "You clicked Button 8"); }) .AddSeparator() - .AddText("hello!", size: SwiftlyS2.Shared.Menus.IMenuTextSize.ExtraLarge) - .SetColor(new(0, 186, 105, 255)) + .AddText("hello!", size: IMenuTextSize.ExtraLarge) .AutoClose(15f) .HasSound(true) .ForceFreeze(); + menu.Builder.Design.SetColor(new(0, 186, 105, 255)); + Core.Menus.OpenMenu(player, menu); } diff --git a/plugin_files/gamedata/cs2/core/signatures.jsonc b/plugin_files/gamedata/cs2/core/signatures.jsonc index 2068cc3af..ff103e4fe 100644 --- a/plugin_files/gamedata/cs2/core/signatures.jsonc +++ b/plugin_files/gamedata/cs2/core/signatures.jsonc @@ -196,5 +196,11 @@ "lib": "server", "windows": "48 89 5C 24 ? 57 48 83 EC ? 33 FF 4C 8B CA 8B D9", "linux": "55 31 D2 48 89 E5 41 56 41 55 41 54" + }, + // Search for "ParticleEffect" and the function contains it + "DispatchParticleEffect": { + "lib": "server", + "windows": "48 89 5C 24 08 48 89 74 24 10 48 89 7C 24 18 4C 89 74 24 20 55 48 8D 6C 24 D1", + "linux": "55 48 89 E5 41 57 41 56 41 55 41 54 41 89 CC 53 48 89 D3" } } \ No newline at end of file diff --git a/src/core/managed/host/host.cpp b/src/core/managed/host/host.cpp index 74e151fcd..bdf67aabb 100644 --- a/src/core/managed/host/host.cpp +++ b/src/core/managed/host/host.cpp @@ -5,6 +5,8 @@ #include #include +#define MIN(a,b) (((a) < (b)) ? (a) : (b)) + hostfxr_initialize_for_runtime_config_fn _initialize_for_runtime_config = nullptr; hostfxr_get_runtime_delegate_fn _get_runtime_delegate = nullptr; hostfxr_close_fn _close = nullptr; @@ -47,6 +49,14 @@ std::string widenedOriginPath; std::string original_path; bool InitializeHostFXR(std::string origin_path) { +#ifdef _WIN32 + for (size_t i = 0; i < origin_path.size(); ++i) { + if (origin_path[i] == '/') { + origin_path[i] = '\\'; + } + } +#endif + original_path = origin_path; #ifdef _WIN32 @@ -55,8 +65,18 @@ bool InitializeHostFXR(std::string origin_path) { widenedOriginPath = origin_path; #endif - hostfxr_lib = load_library((WIN_LIN(widenedOriginPath + L"bin\\win64\\" + L"hostfxr.dll", widenedOriginPath + "bin/linuxsteamrt64/" + "libhostfxr.so")).c_str()); - if (!hostfxr_lib) return false; + // Construct hostfxr library path +#ifdef _WIN32 + std::wstring hostfxr_path = widenedOriginPath + L"bin\\win64\\hostfxr.dll"; +#else + std::string hostfxr_path = widenedOriginPath + "bin/linuxsteamrt64/libhostfxr.so"; +#endif + + hostfxr_lib = load_library(hostfxr_path.c_str()); + if (!hostfxr_lib) { + std::cerr << "[Swiftly] Error: Failed to load hostfxr library from: " << original_path << std::endl; + return false; + } _initialize_for_runtime_config = (hostfxr_initialize_for_runtime_config_fn)get_export(hostfxr_lib, "hostfxr_initialize_for_runtime_config"); if (!_initialize_for_runtime_config) return false; @@ -70,25 +90,52 @@ bool InitializeHostFXR(std::string origin_path) { _set_runtime_prop_value = (hostfxr_set_runtime_property_value_fn)get_export(hostfxr_lib, "hostfxr_set_runtime_property_value"); if (!_set_runtime_prop_value) return false; + // Initialize params structure completely hostfxr_initialize_parameters params; + memset(¶ms, 0, sizeof(params)); params.size = sizeof(hostfxr_initialize_parameters); + #ifdef _WIN32 - std::wstring path = widenedOriginPath + L"bin\\managed\\dotnet"; + std::wstring dotnet_root_path = widenedOriginPath + L"bin\\managed\\dotnet"; + std::wstring runtime_config_path = widenedOriginPath + L"bin\\managed\\SwiftlyS2.CS2.runtimeconfig.json"; #else - std::string path = widenedOriginPath + "bin/managed/dotnet"; + std::string dotnet_root_path = widenedOriginPath + "bin/managed/dotnet"; + std::string runtime_config_path = widenedOriginPath + "bin/managed/SwiftlyS2.CS2.runtimeconfig.json"; #endif - memcpy(dotnet_path, path.c_str(), path.size() * sizeof(char_t) >= 1024 ? 1023 : path.size() * sizeof(char_t)); + // Validate origin path + if (widenedOriginPath.empty()) { + std::cerr << "[Swiftly] Error: Origin path is empty!" << std::endl; + return false; + } + + // Validate constructed paths + if (dotnet_root_path.empty() || runtime_config_path.empty()) { + std::cerr << "[Swiftly] Error: Runtime paths are empty. Origin path: " << original_path << std::endl; + return false; + } + + // Clear and copy dotnet root path to buffer with bounds checking + memset(dotnet_path, 0, sizeof(dotnet_path)); +#ifdef _WIN32 + size_t copy_size = MIN(dotnet_root_path.size() * sizeof(wchar_t), sizeof(dotnet_path) - sizeof(wchar_t)); +#else + size_t copy_size = MIN(dotnet_root_path.size(), sizeof(dotnet_path) - 1); +#endif + memcpy(dotnet_path, dotnet_root_path.c_str(), copy_size); params.dotnet_root = dotnet_path; - int returnCode = _initialize_for_runtime_config((widenedOriginPath + WIN_LIN(L"bin\\managed\\SwiftlyS2.CS2.runtimeconfig.json", "bin/managed/SwiftlyS2.CS2.runtimeconfig.json")).c_str(), ¶ms, &fxrcxt); + // Initialize .NET runtime (using local variable to avoid dangling pointer) + int returnCode = _initialize_for_runtime_config(runtime_config_path.c_str(), ¶ms, &fxrcxt); if (returnCode != 0) { + std::cerr << "[Swiftly] Error: Failed to initialize .NET runtime (code: " << returnCode << ")" << std::endl; + std::cerr << "[Swiftly] Config path: " << original_path << std::endl; _close(fxrcxt); return false; } - _set_runtime_prop_value(fxrcxt, WIN_LIN(L"APP_CONTEXT_BASE_DIRECTORY", "APP_CONTEXT_BASE_DIRECTORY"), WIN_LIN(path.c_str(), dotnet_path)); + _set_runtime_prop_value(fxrcxt, WIN_LIN(L"APP_CONTEXT_BASE_DIRECTORY", "APP_CONTEXT_BASE_DIRECTORY"), dotnet_path); returnCode = _get_runtime_delegate(fxrcxt, hdt_load_assembly_and_get_function_pointer, (void**)&_load_assembly_and_get_function_pointer); if (returnCode != 0 || (void*)_load_assembly_and_get_function_pointer == nullptr) { @@ -104,12 +151,24 @@ bool InitializeDotNetAPI(void* scripting_table, int scripting_table_size) { static custom_loader_fn custom_loader = nullptr; if (custom_loader == nullptr) { + // Construct DLL path as local variable to avoid dangling pointer +#ifdef _WIN32 + std::wstring dll_path = widenedOriginPath + L"bin\\managed\\SwiftlyS2.CS2.dll"; +#else + std::string dll_path = widenedOriginPath + "bin/managed/SwiftlyS2.CS2.dll"; +#endif + int returnCode = _load_assembly_and_get_function_pointer( - (widenedOriginPath + WIN_LIN(L"bin\\managed\\SwiftlyS2.CS2.dll", "bin/managed/SwiftlyS2.CS2.dll")).c_str(), - STR("SwiftlyS2.Entrypoint, SwiftlyS2.CS2"), STR("Start"), UNMANAGEDCALLERSONLY_METHOD, nullptr, (void**)&custom_loader + dll_path.c_str(), + STR("SwiftlyS2.Entrypoint, SwiftlyS2.CS2"), + STR("Start"), + UNMANAGEDCALLERSONLY_METHOD, + nullptr, + (void**)&custom_loader ); if (returnCode != 0 || (void*)custom_loader == nullptr) { + std::cerr << "[Swiftly] Error: Failed to load .NET assembly (code: " << returnCode << ")" << std::endl; return false; } diff --git a/src/scripting/server/player.cpp b/src/scripting/server/player.cpp index 44b34c18d..192cf6164 100644 --- a/src/scripting/server/player.cpp +++ b/src/scripting/server/player.cpp @@ -281,7 +281,7 @@ void Bridge_Player_ExecuteCommand(int playerid, const char* command) CCommand cmd; cmd.Tokenize(command); - ConCommandRef cmdRef(command[0]); + ConCommandRef cmdRef(cmd[0]); if (cmdRef.IsValidRef()) { CCommandContext context(CommandTarget_t::CT_FIRST_SPLITSCREEN_CLIENT, CPlayerSlot(player->GetSlot()));