From a31b9e29b94052228029b25d9c83416943cd3626 Mon Sep 17 00:00:00 2001 From: Az <56699741+AlchlcDvl@users.noreply.github.com> Date: Thu, 15 Oct 2026 01:41:56 -0400 Subject: [PATCH] improve options system --- .editorconfig | 3 + MiraAPI.Example/Options/ExampleOptions.cs | 54 ++++- MiraAPI.Example/Options/ExampleOptions2.cs | 4 +- .../Delegate/ModdedOptionChangedAttribute.cs | 88 +++++++ .../Delegate/ModdedOptionGetterAttribute.cs | 88 +++++++ .../Delegate/ModdedOptionMemberAttribute.cs | 226 ++++++++++++++++++ .../Delegate/ModdedOptionSetterAttribute.cs | 88 +++++++ .../ModdedOptionVisibilityAttribute.cs | 189 +++++++++++++++ .../Attributes/ModdedEnumOptionAttribute.cs | 3 +- .../ModdedOptionVisibilityAttribute.cs | 201 ---------------- .../Attributes/ModdedStringOptionAttribute.cs | 39 +++ .../ModdedEnumOptionListAttribute.cs | 7 +- .../ModdedNumberOptionListAttribute.cs | 9 +- .../OptionList/ModdedStringOptionList.cs | 42 ++++ MiraAPI/GameOptions/IModdedOption.cs | 21 ++ MiraAPI/GameOptions/ModdedOptionsManager.cs | 99 ++++++-- .../OptionTypes/ModdedEnumOption.cs | 12 +- .../GameOptions/OptionTypes/ModdedOption.cs | 74 ++++-- .../OptionTypes/ModdedOptionList.cs | 3 + .../OptionTypes/ModdedStringOption.cs | 98 ++++---- MiraAPI/MiraApiPlugin.cs | 6 + MiraAPI/Patches/Options/SettingPatches.cs | 2 + MiraAPI/Presets/OptionPreset.cs | 3 - MiraAPI/Utilities/MiraNumberSuffixes.cs | 5 + 24 files changed, 1060 insertions(+), 304 deletions(-) create mode 100644 MiraAPI/GameOptions/Attributes/Delegate/ModdedOptionChangedAttribute.cs create mode 100644 MiraAPI/GameOptions/Attributes/Delegate/ModdedOptionGetterAttribute.cs create mode 100644 MiraAPI/GameOptions/Attributes/Delegate/ModdedOptionMemberAttribute.cs create mode 100644 MiraAPI/GameOptions/Attributes/Delegate/ModdedOptionSetterAttribute.cs create mode 100644 MiraAPI/GameOptions/Attributes/Delegate/ModdedOptionVisibilityAttribute.cs delete mode 100644 MiraAPI/GameOptions/Attributes/ModdedOptionVisibilityAttribute.cs create mode 100644 MiraAPI/GameOptions/Attributes/ModdedStringOptionAttribute.cs create mode 100644 MiraAPI/GameOptions/Attributes/OptionList/ModdedStringOptionList.cs diff --git a/.editorconfig b/.editorconfig index 8d85d5f..6daa6a7 100644 --- a/.editorconfig +++ b/.editorconfig @@ -101,3 +101,6 @@ dotnet_diagnostic.IDE0046.severity = none # S1244: Floating point numbers should not be tested for equality dotnet_diagnostic.S1244.severity = none + +# S2325: Methods and properties that don't access instance data should be static +dotnet_diagnostic.S2325.severity = none diff --git a/MiraAPI.Example/Options/ExampleOptions.cs b/MiraAPI.Example/Options/ExampleOptions.cs index cba80e6..c846308 100644 --- a/MiraAPI.Example/Options/ExampleOptions.cs +++ b/MiraAPI.Example/Options/ExampleOptions.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using MiraAPI.GameOptions; using MiraAPI.GameOptions.Attributes; using MiraAPI.Utilities; @@ -6,33 +7,68 @@ using UnityEngine; namespace MiraAPI.Example.Options; +[SuppressMessage("Style", "IDE0060:Remove unused parameter", Justification = "Demo.")] +[SuppressMessage("Performance", "CA1822:Mark members as static", Justification = "Read above.")] +[SuppressMessage("Minor Code Smell", "S3400:Methods should not return constants", Justification = "Read above.")] public class ExampleOptions : AbstractOptionGroup { public override string GroupName => "options.exampleOptions1"; public override Color GroupColor => Color.green; [ModdedToggleOption("options.exampleOptions1.toggleOpt1")] - public bool ToggleOpt { get; set; } = false; + [ModdedOptionVisibility] // Automatically resolves to using ToggleOpt1Visibility, can also target toggle options + public bool ToggleOpt1 { get; set; } = false; - [ModdedToggleOption("options.exampleOptions1.toggleOpt1")] + [ModdedToggleOption("options.exampleOptions1.toggleOpt2")] + [ModdedOptionVisibility(nameof(ToggleOpt1))] // ToggleOpt2 will be visible only when ToggleOpt1 is true public bool ToggleOpt2 { get; set; } = true; - [ModdedToggleOption("Toggle Opt 3")] - [ModdedOptionVisibility(nameof(ToggleOpt2))] // ToggleOpt3 will be visible only when ToggleOpt2 is true - public bool ToggleOpt3 { get; set; } = true; - [ModdedNumberOption("options.exampleOptions1.numberOpt", min: 0, max: 10, increment: .25f, formatString: "0.00", suffixType: MiraNumberSuffixes.Percent)] + [ModdedOptionVisibility(nameof(NumberOptVisibility))] // Can also target static methods public float NumberOpt { get; set; } = 4f; - [ModdedEnumOption("options.exampleOptions1.bestApi", typeof(BestApi), ["Mira API", "Mitochondria", "Reactor"])] - public BestApi Opt { get; set; } = BestApi.MiraAPI; + [ModdedEnumOption("options.exampleOptions1.bestApi", typeof(Api), ["Mira API", "Mitochondria", "Reactor"])] + + // These three can target both instance and static methods + [ModdedOptionGetter(nameof(GetApi))] // Can also automatically resolve to {Option Name}Getter + [ModdedOptionSetter(nameof(SetApi))] // Can also automatically resolve to {Option Name}Setter + [ModdedOptionChanged(nameof(ApiChanged))] // Can also automatically resolve to {Option Name}Changed + public Api BestApi { get; set; } = Api.MiraAPI; + + [ModdedEnumOption("options.exampleOptions1.worstApi", ["Mira API", "Mitochondria", "Reactor"])] + public Api WorstApi { get; set; } = Api.MiraAPI; [ModdedEnumOptionList("Value State {0}", ["None", "Primary", "Secondary"])] public List ValueStates { get; } = [ValueState.Primary, ValueState.Secondary, ValueState.None, ValueState.None, ValueState.None]; + + public bool ToggleOpt1Visibility() + { + return true; + } + + public static bool NumberOptVisibility() + { + return true; + } + + public static Api GetApi(Api currentValue) + { + return currentValue; + } + + public static Api SetApi(Api newValue, Api oldValue) + { + return default; + } + + public static void ApiChanged(Api currentValue) + { + // Something + } } -public enum BestApi +public enum Api { MiraAPI, Mitochondria, diff --git a/MiraAPI.Example/Options/ExampleOptions2.cs b/MiraAPI.Example/Options/ExampleOptions2.cs index 24a061c..bdc5a1b 100644 --- a/MiraAPI.Example/Options/ExampleOptions2.cs +++ b/MiraAPI.Example/Options/ExampleOptions2.cs @@ -7,9 +7,7 @@ namespace MiraAPI.Example.Options; public class ExampleOptions2 : AbstractOptionGroup { public override string GroupName => "options.exampleOptions2"; - public override uint GroupPriority => 0; // This group will be displayed first. The default value is uint.MaxValue. - public override MenuCategory ParentMenu => MenuCategory.CustomTwo; public ModdedPlayerOption PlayerOption2 { get; } = new("options.exampleOptions2.youMustChoseAPlayer", false); @@ -27,6 +25,8 @@ public class ExampleOptions2 : AbstractOptionGroup public ModdedEnumOption EnumOpt { get; } = new("options.exampleOptions2.enumOpt", 0) { ChangedEvent = x => Logger.Info($"changed Enum Opt to {x}"), + Getter = currentValue => currentValue, + Setter = (newValue, oldValue) => newValue, }; } diff --git a/MiraAPI/GameOptions/Attributes/Delegate/ModdedOptionChangedAttribute.cs b/MiraAPI/GameOptions/Attributes/Delegate/ModdedOptionChangedAttribute.cs new file mode 100644 index 0000000..0d75a11 --- /dev/null +++ b/MiraAPI/GameOptions/Attributes/Delegate/ModdedOptionChangedAttribute.cs @@ -0,0 +1,88 @@ +using System; +using System.Reflection; + +namespace MiraAPI.GameOptions.Attributes; + +/// +/// Attribute to set the delegate in a or . +/// +/// The method to get the changed event from. +/// The type the method is in. +[AttributeUsage(AttributeTargets.Property)] +public sealed class ModdedOptionChangedAttribute(string? methodName = null, Type? holderType = null) + : ModdedOptionMemberAttribute(methodName, holderType, "Changed") +{ + internal Delegate? GetChanged(AbstractOptionGroup group, PropertyInfo property, IModdedOption option) + { + if (ResolveMethod(group, property, out Type type, out var instanceGetter) is not { } method) + { + Error($"Method {MemberName} does not exist in {type.FullName}"); + return null; + } + + if (method.ReturnType != typeof(void)) + { + Error($"{MemberName}'s return type is not void."); + return null; + } + + var parameters = method.GetParameters(); + + if (parameters.Length != 1) + { + Error($"{MemberName} must have exactly one parameter."); + return null; + } + + var valueType = option.GetValueType(); + + if (parameters[0].ParameterType != valueType) + { + Error($"{MemberName}'s parameter must be of the type {valueType.Name}."); + return null; + } + + var delegateType = typeof(Action<>).MakeGenericType(valueType); + return CompileDynamicDelegate(type, method, instanceGetter, typeof(void), [valueType], delegateType, "Changed"); + } + + internal Func? GetListChanged(AbstractOptionGroup group, PropertyInfo property, Type valueType) + { + if (ResolveMethod(group, property, out Type type, out var instanceGetter) is not { } method) + { + Error($"Method {MemberName} does not exist in {type.FullName}"); + return null; + } + + if (method.ReturnType != typeof(void)) + { + Error($"{MemberName}'s return type is not void."); + return null; + } + + var parameters = method.GetParameters(); + + if (parameters.Length is < 1 or > 2) + { + Error($"{MemberName} must have one parameter ({valueType.Name}) or two parameters ({valueType.Name}, int)."); + return null; + } + + if (parameters[0].ParameterType != valueType) + { + Error($"{MemberName}'s first parameter must be of the type {valueType.Name}."); + return null; + } + + var hasIndex = parameters.Length == 2; + + if (hasIndex && parameters[1].ParameterType != typeof(int)) + { + Error($"{MemberName}'s second parameter must be an int (the list index)."); + return null; + } + + var delegateType = typeof(Action<>).MakeGenericType(valueType); + return CompileDynamicListDelegate(type, method, instanceGetter, typeof(void), [valueType], delegateType, "ListChanged", hasIndex); + } +} diff --git a/MiraAPI/GameOptions/Attributes/Delegate/ModdedOptionGetterAttribute.cs b/MiraAPI/GameOptions/Attributes/Delegate/ModdedOptionGetterAttribute.cs new file mode 100644 index 0000000..3434951 --- /dev/null +++ b/MiraAPI/GameOptions/Attributes/Delegate/ModdedOptionGetterAttribute.cs @@ -0,0 +1,88 @@ +using System; +using System.Reflection; + +namespace MiraAPI.GameOptions.Attributes; + +/// +/// Attribute to set the delegate in a or . +/// +/// The method to get the getter from. +/// The type the method is in. +[AttributeUsage(AttributeTargets.Property)] +public sealed class ModdedOptionGetterAttribute(string? methodName = null, Type? holderType = null) + : ModdedOptionMemberAttribute(methodName, holderType, "Getter") +{ + internal Delegate? GetGetter(AbstractOptionGroup group, PropertyInfo property, IModdedOption option) + { + if (ResolveMethod(group, property, out Type type, out var instanceGetter) is not { } method) + { + Error($"Method {MemberName} does not exist in {type.FullName}"); + return null; + } + + var valueType = option.GetValueType(); + + if (method.ReturnType != valueType) + { + Error($"{MemberName}'s return type is not {valueType.Name}."); + return null; + } + + var parameters = method.GetParameters(); + + if (parameters.Length != 1) + { + Error($"{MemberName} must have exactly one parameter."); + return null; + } + + if (parameters[0].ParameterType != valueType) + { + Error($"{MemberName}'s parameter must be of the type {valueType.Name}."); + return null; + } + + var delegateType = typeof(Func<,>).MakeGenericType(valueType, valueType); + return CompileDynamicDelegate(type, method, instanceGetter, valueType, [valueType], delegateType, "Getter"); + } + + internal Func? GetListGetter(AbstractOptionGroup group, PropertyInfo property, Type valueType) + { + if (ResolveMethod(group, property, out Type type, out var instanceGetter) is not { } method) + { + Error($"Method {MemberName} does not exist in {type.FullName}"); + return null; + } + + if (method.ReturnType != valueType) + { + Error($"{MemberName}'s return type is not {valueType.Name}."); + return null; + } + + var parameters = method.GetParameters(); + + if (parameters.Length is < 1 or > 2) + { + Error($"{MemberName} must have one parameter ({valueType.Name}) or two parameters ({valueType.Name}, int)."); + return null; + } + + if (parameters[0].ParameterType != valueType) + { + Error($"{MemberName}'s first parameter must be of the type {valueType.Name}."); + return null; + } + + var hasIndex = parameters.Length == 2; + + if (hasIndex && parameters[1].ParameterType != typeof(int)) + { + Error($"{MemberName}'s second parameter must be an int (the list index)."); + return null; + } + + var delegateType = typeof(Func<,>).MakeGenericType(valueType, valueType); + return CompileDynamicListDelegate(type, method, instanceGetter, valueType, [valueType], delegateType, "ListGetter", hasIndex); + } +} diff --git a/MiraAPI/GameOptions/Attributes/Delegate/ModdedOptionMemberAttribute.cs b/MiraAPI/GameOptions/Attributes/Delegate/ModdedOptionMemberAttribute.cs new file mode 100644 index 0000000..1db3700 --- /dev/null +++ b/MiraAPI/GameOptions/Attributes/Delegate/ModdedOptionMemberAttribute.cs @@ -0,0 +1,226 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Reflection; +using System.Reflection.Emit; + +namespace MiraAPI.GameOptions.Attributes; + +/// +/// Base attribute for resolving and compiling option delegate members (, , , and ). +/// +/// The name of the target member, or to use the property name with . +/// The type containing the target member, or to use the declaring type. +/// The default suffix appended to the property name when is not specified. +[AttributeUsage(AttributeTargets.Property)] +public abstract class ModdedOptionMemberAttribute(string? memberName, Type? holderType, string defaultSuffix) : Attribute +{ + /// + /// Gets the resolved name of the target member. + /// + protected string? MemberName { get; private set; } = memberName; + + /// + /// Resolves the target (property or method) and its associated singleton instance getter, if applicable. + /// + /// The option group instance the property belongs to. + /// The option property decorated with this attribute. + /// When this method returns, contains the resolved expected to hold the target member. + /// + /// When this method returns, contains the getter + /// if the resolved member is an instance member on an ; otherwise, . + /// + /// The resolved if found; otherwise, . + [SuppressMessage("Major Code Smell", "S3011:Reflection should not be used to increase accessibility of classes, methods, or fields", Justification = "It is safe.")] + protected MemberInfo? ResolveMember(AbstractOptionGroup group, PropertyInfo property, out Type type, out MethodInfo? instanceGetter) + { + var groupType = group.GetType(); + var flags = BindingFlags.Static | BindingFlags.Public; + + type = holderType ?? groupType; + MemberName ??= $"{property.Name}{defaultSuffix}"; + + if (type.IsAssignableTo(typeof(AbstractOptionGroup))) + { + flags |= BindingFlags.Instance; + + if (type == groupType) + flags |= BindingFlags.NonPublic; + + instanceGetter = typeof(OptionGroupSingleton<>) + .MakeGenericType(type) + .GetProperty(nameof(OptionGroupSingleton<>.Instance))! + .GetGetMethod(); + } + else + { + instanceGetter = null; + } + + var member = type.GetMember(MemberName, flags).FirstOrDefault(); + + if (instanceGetter != null) + { + var method = (member as PropertyInfo)?.GetGetMethod(true) ?? member as MethodInfo; + if (method is { IsStatic: true }) + instanceGetter = null; + } + + return member; + } + + /// + /// Resolves the target and its associated singleton instance getter, if applicable. + /// + /// The option group instance the property belongs to. + /// The option property decorated with this attribute. + /// When this method returns, contains the resolved expected to hold the target method. + /// + /// When this method returns, contains the getter + /// if the resolved method is an instance method on an ; otherwise, . + /// + /// The resolved if found; otherwise, . + protected MethodInfo? ResolveMethod(AbstractOptionGroup group, PropertyInfo property, out Type type, out MethodInfo? instanceGetter) + { + return ResolveMember(group, property, out type, out instanceGetter) as MethodInfo; + } + + /// + /// Compiles a using IL generation to invoke the resolved static or instance . + /// + /// The that owns the generated and holds the target method. + /// The target to invoke. + /// + /// The static getter used to push the target instance onto the evaluation stack for instance methods, + /// or if is static. + /// + /// The return of the generated delegate (or for actions). + /// The parameter types accepted by the generated delegate. + /// The concrete delegate to create (e.g., or ). + /// A descriptive suffix appended to the generated name for debugging and stack traces. + /// Whether to push the delegate parameters onto the stack before invoking . + /// An optional callback to emit additional IL instructions before returning. + /// A compiled of type bound to the generated IL method. + protected static Delegate CompileDynamicDelegate( + Type ownerType, + MethodInfo method, + MethodInfo? instanceGetter, + Type returnType, + Type[] paramTypes, + Type delegateType, + string suffix, + bool forwardParameters = true, + Action? postProcessCall = null) + { + var dm = new DynamicMethod($"{ownerType.Name}_{method.Name}_{suffix}", returnType, paramTypes, ownerType, true); + var il = dm.GetILGenerator(); + + if (!method.IsStatic && instanceGetter != null) + il.Emit(OpCodes.Call, instanceGetter); + + if (forwardParameters) + { + for (byte i = 0; i < paramTypes.Length; i++) + { + if (i == 0) + il.Emit(OpCodes.Ldarg_0); + else if (i == 1) + il.Emit(OpCodes.Ldarg_1); + else if (i == 2) + il.Emit(OpCodes.Ldarg_2); + else if (i == 3) + il.Emit(OpCodes.Ldarg_3); + else + il.Emit(OpCodes.Ldarg_S, i); + } + } + + var callOp = method.IsStatic ? OpCodes.Call : OpCodes.Callvirt; + il.Emit(callOp, method); + + postProcessCall?.Invoke(il); + + il.Emit(OpCodes.Ret); + + return dm.CreateDelegate(delegateType); + } + + /// + /// Compiles a factory function that provides a for each index in an option list. + /// If is and is , + /// a single is compiled once and reused for all indices. + /// + /// The that owns the generated and holds the target method. + /// The target to invoke. + /// + /// The static getter used to push the target instance onto the evaluation stack for instance methods, + /// or if is static. + /// + /// The return of the generated delegate (or for actions). + /// The parameter types accepted by the generated delegate. + /// The concrete delegate to create. + /// A descriptive suffix appended to the generated name for debugging and stack traces. + /// Whether the target takes the list index as its final parameter. + /// An optional index-independent callback to emit additional IL instructions after is called. + /// An optional index-dependent callback to emit additional IL instructions after is called. + /// A factory function that returns a compiled of type for a given list index. + protected static Func CompileDynamicListDelegate( + Type ownerType, + MethodInfo method, + MethodInfo? instanceGetter, + Type returnType, + Type[] baseParamTypes, + Type delegateType, + string suffix, + bool hasIndexParameter, + Action? postProcessCall = null, + Action? postProcessListCall = null) + { + if (!hasIndexParameter && postProcessListCall is null) + { + var sharedDelegate = CompileDynamicDelegate( + ownerType, + method, + instanceGetter, + returnType, + baseParamTypes, + delegateType, + suffix, + forwardParameters: baseParamTypes.Length > 0, + postProcessCall: postProcessCall); + + return _ => sharedDelegate; + } + + return index => + { + var dm = new DynamicMethod($"{ownerType.Name}_{method.Name}_{suffix}_{index}", returnType, baseParamTypes, ownerType, true); + var il = dm.GetILGenerator(); + + if (!method.IsStatic && instanceGetter != null) + il.Emit(OpCodes.Call, instanceGetter); + + for (byte i = 0; i < baseParamTypes.Length; i++) + { + if (i == 0) il.Emit(OpCodes.Ldarg_0); + else if (i == 1) il.Emit(OpCodes.Ldarg_1); + else if (i == 2) il.Emit(OpCodes.Ldarg_2); + else if (i == 3) il.Emit(OpCodes.Ldarg_3); + else il.Emit(OpCodes.Ldarg_S, i); + } + + if (hasIndexParameter) + il.Emit(OpCodes.Ldc_I4, index); + + var callOp = method.IsStatic ? OpCodes.Call : OpCodes.Callvirt; + il.Emit(callOp, method); + + postProcessCall?.Invoke(il); + postProcessListCall?.Invoke(il, index); + + il.Emit(OpCodes.Ret); + + return dm.CreateDelegate(delegateType); + }; + } +} diff --git a/MiraAPI/GameOptions/Attributes/Delegate/ModdedOptionSetterAttribute.cs b/MiraAPI/GameOptions/Attributes/Delegate/ModdedOptionSetterAttribute.cs new file mode 100644 index 0000000..370c7f9 --- /dev/null +++ b/MiraAPI/GameOptions/Attributes/Delegate/ModdedOptionSetterAttribute.cs @@ -0,0 +1,88 @@ +using System; +using System.Reflection; + +namespace MiraAPI.GameOptions.Attributes; + +/// +/// Attribute to set the delegate in a or . +/// +/// The method to get the setter from. +/// The type the method is in. +[AttributeUsage(AttributeTargets.Property)] +public sealed class ModdedOptionSetterAttribute(string? methodName = null, Type? holderType = null) + : ModdedOptionMemberAttribute(methodName, holderType, "Setter") +{ + internal Delegate? GetSetter(AbstractOptionGroup group, PropertyInfo property, IModdedOption option) + { + if (ResolveMethod(group, property, out Type type, out var instanceGetter) is not { } method) + { + Error($"Method {MemberName} does not exist in {type.FullName}"); + return null; + } + + var valueType = option.GetValueType(); + + if (method.ReturnType != valueType) + { + Error($"{MemberName}'s return type is not {valueType.Name}."); + return null; + } + + var parameters = method.GetParameters(); + + if (parameters.Length != 2) + { + Error($"{MemberName} must have exactly two parameters."); + return null; + } + + if (parameters[0].ParameterType != valueType || parameters[1].ParameterType != valueType) + { + Error($"{MemberName}'s parameters must both be of the type {valueType.Name}."); + return null; + } + + var delegateType = typeof(Func<,,>).MakeGenericType(valueType, valueType, valueType); + return CompileDynamicDelegate(type, method, instanceGetter, valueType, [valueType, valueType], delegateType, "Setter"); + } + + internal Func? GetListSetter(AbstractOptionGroup group, PropertyInfo property, Type valueType) + { + if (ResolveMethod(group, property, out Type type, out var instanceGetter) is not { } method) + { + Error($"Method {MemberName} does not exist in {type.FullName}"); + return null; + } + + if (method.ReturnType != valueType) + { + Error($"{MemberName}'s return type is not {valueType.Name}."); + return null; + } + + var parameters = method.GetParameters(); + + if (parameters.Length is < 2 or > 3) + { + Error($"{MemberName} must have two parameters ({valueType.Name}, {valueType.Name}) or three parameters ({valueType.Name}, {valueType.Name}, int)."); + return null; + } + + if (parameters[0].ParameterType != valueType || parameters[1].ParameterType != valueType) + { + Error($"{MemberName}'s first two parameters must both be of the type {valueType.Name}."); + return null; + } + + var hasIndex = parameters.Length == 3; + + if (hasIndex && parameters[2].ParameterType != typeof(int)) + { + Error($"{MemberName}'s third parameter must be an int (the list index)."); + return null; + } + + var delegateType = typeof(Func<,,>).MakeGenericType(valueType, valueType, valueType); + return CompileDynamicListDelegate(type, method, instanceGetter, valueType, [valueType, valueType], delegateType, "ListSetter", hasIndex); + } +} diff --git a/MiraAPI/GameOptions/Attributes/Delegate/ModdedOptionVisibilityAttribute.cs b/MiraAPI/GameOptions/Attributes/Delegate/ModdedOptionVisibilityAttribute.cs new file mode 100644 index 0000000..60e494d --- /dev/null +++ b/MiraAPI/GameOptions/Attributes/Delegate/ModdedOptionVisibilityAttribute.cs @@ -0,0 +1,189 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using System.Reflection.Emit; +using MiraAPI.GameOptions.OptionTypes; + +namespace MiraAPI.GameOptions.Attributes; + +/// +/// Attribute to set the delegate in a or . +/// +/// The member to get the visibility function from. +/// The type the member is in. +[AttributeUsage(AttributeTargets.Property)] +public sealed class ModdedOptionVisibilityAttribute(string? memberName = null, Type? holderType = null) + : ModdedOptionMemberAttribute(memberName, holderType, "Visibility") +{ + private static readonly MethodInfo ModdedOptionBoolValueGetter = typeof(ModdedOption).GetProperty(nameof(ModdedOption<>.Value))!.GetGetMethod()!; + private static readonly MethodInfo ListBoolIndexerGetter = typeof(IList).GetProperty("Item")!.GetGetMethod()!; + + internal Func? GetVisibility(AbstractOptionGroup group, PropertyInfo property) + { + if (ResolveMember(group, property, out Type type, out var instanceGetter) is not { } member) + { + Error($"Member {MemberName} does not exist in {type.FullName}"); + return null; + } + + MethodInfo? targetMethod; + Action? postProcess = null; + + if (member is PropertyInfo vProperty) + { + if (vProperty.PropertyType.IsAssignableTo(typeof(ModdedOption))) + { + postProcess = il => il.Emit(OpCodes.Callvirt, ModdedOptionBoolValueGetter); + } + else if (!vProperty.PropertyType.IsAssignableTo(typeof(bool))) + { + Error($"Property {MemberName} is not a bool or a bool ModdedOption."); + return null; + } + + if (vProperty.GetIndexParameters().Length > 0) + { + Error($"Property {MemberName} cannot be an indexer."); + return null; + } + + targetMethod = vProperty.GetGetMethod(true); + + if (targetMethod is null) + { + Error($"Property {MemberName} does not have a getter."); + return null; + } + } + else if (member is MethodInfo vMethod) + { + if (vMethod.ReturnType != typeof(bool)) + { + Error($"Method {MemberName} does not return a bool."); + return null; + } + + if (vMethod.GetParameters().Length > 0) + { + Error($"Method {MemberName} has too many parameters."); + return null; + } + + targetMethod = vMethod; + } + else + { + Error($"Member {MemberName} of {type.FullName} is not valid for Visible function."); + return null; + } + + return (Func)CompileDynamicDelegate(type, targetMethod, instanceGetter, typeof(bool), Type.EmptyTypes, typeof(Func), "Visibility", false, postProcess); + } + + internal Func? GetListVisibility(AbstractOptionGroup group, PropertyInfo property) + { + if (ResolveMember(group, property, out Type type, out var instanceGetter) is not { } member) + { + Error($"Member {MemberName} does not exist in {type.FullName}"); + return null; + } + + MethodInfo? targetMethod; + Action? postProcess = null; + Action? postProcessList = null; + + var hasIndexParam = false; + + if (member is PropertyInfo vProperty) + { + var isListBool = false; + + if (vProperty.PropertyType.IsAssignableTo(typeof(IList))) + { + isListBool = true; + postProcessList = (il, index) => + { + il.Emit(OpCodes.Ldc_I4, index); + il.Emit(OpCodes.Callvirt, ListBoolIndexerGetter); + }; + } + else if (vProperty.PropertyType.IsAssignableTo(typeof(ModdedOption))) + { + postProcess = il => il.Emit(OpCodes.Callvirt, ModdedOptionBoolValueGetter); + } + else if (!vProperty.PropertyType.IsAssignableTo(typeof(bool))) + { + Error($"Property {MemberName} is not a bool value or indexer, a list of bools, or a bool ModdedOption."); + return null; + } + + var indexParams = vProperty.GetIndexParameters(); + if (indexParams.Length > 1) + { + Error($"Indexer {MemberName} has too many parameters."); + return null; + } + + if (isListBool && indexParams.Length != 0) + { + Error($"Property {MemberName} cannot be an indexer of lists of bools."); + return null; + } + + if (indexParams.Length == 1) + { + if (indexParams[0].ParameterType != typeof(int)) + { + Error($"Indexer {MemberName}'s parameter is not an int."); + return null; + } + + hasIndexParam = true; + } + + targetMethod = vProperty.GetGetMethod(true); + + if (targetMethod is null) + { + Error($"Property {MemberName} does not have a getter."); + return null; + } + } + else if (member is MethodInfo vMethod) + { + if (vMethod.ReturnType != typeof(bool)) + { + Error($"Method {MemberName} does not return a bool."); + return null; + } + + var paramList = vMethod.GetParameters(); + + if (paramList.Length > 1) + { + Error($"Method {MemberName} has too many parameters."); + return null; + } + + if (paramList.Length == 1) + { + if (paramList[0].ParameterType != typeof(int)) + { + Error($"Method {MemberName}'s parameter is not an int."); + return null; + } + + hasIndexParam = true; + } + + targetMethod = vMethod; + } + else + { + Error($"Member {MemberName} of {type.FullName} is not valid for Visibility function."); + return null; + } + + return CompileDynamicListDelegate(type, targetMethod, instanceGetter, typeof(bool), Type.EmptyTypes, typeof(Func), "ListVisibility", hasIndexParam, postProcess, postProcessList); + } +} diff --git a/MiraAPI/GameOptions/Attributes/ModdedEnumOptionAttribute.cs b/MiraAPI/GameOptions/Attributes/ModdedEnumOptionAttribute.cs index cf517ba..22f4369 100644 --- a/MiraAPI/GameOptions/Attributes/ModdedEnumOptionAttribute.cs +++ b/MiraAPI/GameOptions/Attributes/ModdedEnumOptionAttribute.cs @@ -41,10 +41,11 @@ public class ModdedEnumOptionAttribute(string title, Type enumType, string[]? va /// Attribute for creating an enum option. /// /// The enum type. +/// The title of the option. [AttributeUsage(AttributeTargets.Property)] public class ModdedEnumOptionAttribute(string title, string[]? values = null) : ModdedOptionAttribute(title) - where T : Enum + where T : struct, Enum { internal override IModdedOption CreateOption(object? value, PropertyInfo property) { diff --git a/MiraAPI/GameOptions/Attributes/ModdedOptionVisibilityAttribute.cs b/MiraAPI/GameOptions/Attributes/ModdedOptionVisibilityAttribute.cs deleted file mode 100644 index 690ba36..0000000 --- a/MiraAPI/GameOptions/Attributes/ModdedOptionVisibilityAttribute.cs +++ /dev/null @@ -1,201 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.Linq; -using System.Reflection; -using MiraAPI.GameOptions.OptionTypes; - -namespace MiraAPI.GameOptions.Attributes; - -/// -/// Attribute to modify a property with a or . -/// -/// -/// Initializes a new instance of the class. -/// -/// The type the member is in. -/// The member to get the visibility function from. -[AttributeUsage(AttributeTargets.Property)] -public sealed class ModdedOptionVisibilityAttribute(Type? holderType = null, string? memberName = null) : Attribute -{ - /// - /// Initializes a new instance of the class. - /// - /// The member to get the visibility function from. - public ModdedOptionVisibilityAttribute(string memberName) - : this(null, memberName) - { - } - - internal Func? GetVisibility(AbstractOptionGroup group, PropertyInfo property) - { - if (GetVisibilityMember(group, property, out Type type, out var instanceGet) is not { } member) - { - Error($"Member {memberName} does not exist in {type.FullName}"); - return null; - } - - if (member is PropertyInfo vProperty) - { - if (!vProperty.PropertyType.IsAssignableTo(typeof(bool)) && - !vProperty.PropertyType.IsAssignableTo(typeof(ModdedOption))) - { - Error($"Property {memberName} is not a bool or a bool ModdedOption."); - return null; - } - - var indexParams = vProperty.GetIndexParameters(); - if (indexParams.Length > 0) - { - Error($"Property {memberName} cannot be an indexer."); - return null; - } - - return () => (bool)vProperty.GetValue(instanceGet?.Invoke())!; - } - - if (member is MethodInfo vMethod) - { - if (vMethod.ReturnType != typeof(bool)) - { - Error($"Method {memberName} does not return a bool."); - return null; - } - - var paramList = vMethod.GetParameters(); - if (paramList.Length > 0) - { - Error($"Method {memberName} has too many parameters."); - return null; - } - - return () => (bool)vMethod.Invoke(instanceGet?.Invoke(), null)!; - } - - Error($"Member {memberName} of {type.FullName} is not valid for Visible function."); - return null; - } - - internal Func? GetListVisibility(AbstractOptionGroup group, PropertyInfo property) - { - if (GetVisibilityMember(group, property, out Type type, out var instanceGet) is not { } member) - { - Error($"Member {memberName} does not exist in {type.FullName}"); - return null; - } - - if (member is PropertyInfo vProperty) - { - if (!vProperty.PropertyType.IsAssignableTo(typeof(bool)) && - !vProperty.PropertyType.IsAssignableTo(typeof(ModdedOption)) && - !vProperty.PropertyType.IsAssignableTo(typeof(IList))) - { - Error($"Property {memberName} is not a bool value or indexer, a list of bools, or a bool ModdedOption."); - return null; - } - - var indexParams = vProperty.GetIndexParameters(); - if (indexParams.Length > 1) - { - Error($"Indexer {memberName} has too many parameters."); - return null; - } - - if (vProperty.PropertyType.IsAssignableTo(typeof(IList))) - { - if (indexParams.Length != 0) - { - Error($"Property {memberName} cannot be an indexer of lists of bools."); - return null; - } - - return i => ((IList)vProperty.GetValue(instanceGet?.Invoke())!)[i]; - } - - if (indexParams.Length == 0) - { - return _ => (bool)vProperty.GetValue(instanceGet?.Invoke())!; - } - - if (indexParams[0].ParameterType != typeof(int)) - { - Error($"Indexer {memberName}'s parameter is not an int."); - return null; - } - - return i => (bool)vProperty.GetValue(instanceGet?.Invoke(), [i])!; - } - - if (member is MethodInfo vMethod) - { - if (vMethod.ReturnType != typeof(bool)) - { - Error($"Method {memberName} does not return a bool."); - return null; - } - - var paramList = vMethod.GetParameters(); - if (paramList.Length > 1) - { - Error($"Method {memberName} has too many parameters."); - return null; - } - - if (paramList.Length == 0) - { - return _ => (bool)vMethod.Invoke(instanceGet?.Invoke(), null)!; - } - - if (paramList[0].ParameterType != typeof(int)) - { - Error($"Method {memberName}'s parameter is not an int."); - return null; - } - - return i => (bool)vMethod.Invoke(instanceGet?.Invoke(), [i])!; - } - - Error($"Member {memberName} of {type.FullName} is not valid for Visible function."); - return null; - } - - [SuppressMessage("Major Code Smell", "S3011:Reflection should not be used to increase accessibility of classes, methods, or fields", Justification = "It is safe.")] - private MemberInfo? GetVisibilityMember(AbstractOptionGroup group, PropertyInfo property, out Type type, out Func? instanceGet) - { - Type groupType = group.GetType(); - BindingFlags flags = BindingFlags.Static | BindingFlags.Public; - - instanceGet = null; - type = holderType ?? groupType; - memberName ??= $"{property.Name}Visible"; - if (type.IsAssignableTo(typeof(AbstractOptionGroup))) - { - flags |= BindingFlags.Instance; - - if (type == groupType) - { - flags |= BindingFlags.NonPublic; - instanceGet = () => group; - } - else - { - var instanceProp = typeof(OptionGroupSingleton<>).MakeGenericType(type) - .GetProperty(nameof(OptionGroupSingleton<>.Instance))!; - instanceGet = () => instanceProp.GetValue(null)!; - } - } - - MemberInfo? member = type.GetMember(memberName, flags).FirstOrDefault(); - if (instanceGet != null) - { - MethodInfo? method = (member as PropertyInfo)?.GetGetMethod() - ?? member as MethodInfo; - if (method != null && method.IsStatic) - { - instanceGet = null; - } - } - - return member; - } -} diff --git a/MiraAPI/GameOptions/Attributes/ModdedStringOptionAttribute.cs b/MiraAPI/GameOptions/Attributes/ModdedStringOptionAttribute.cs new file mode 100644 index 0000000..b0ff5e2 --- /dev/null +++ b/MiraAPI/GameOptions/Attributes/ModdedStringOptionAttribute.cs @@ -0,0 +1,39 @@ +using System; +using System.Reflection; +using MiraAPI.GameOptions.OptionTypes; +using MiraAPI.Translation; + +namespace MiraAPI.GameOptions.Attributes; + +/// +/// Attribute for a . +/// +/// The title of the option. +/// An option list of string value. +[AttributeUsage(AttributeTargets.Property)] +public class ModdedStringOptionAttribute( + string title, + string[] values) + : ModdedOptionAttribute(title) +{ + internal override IModdedOption? CreateOption(object? value, PropertyInfo property) + { + var option = new ModdedStringOption(Title.Translate(), (string)(value ?? string.Empty), values); + return option; + } + + /// + public override object GetValue() + { + return HolderOption is ModdedStringOption opt + ? (object)opt.Value + : throw new InvalidOperationException($"Holder option for {Title} is not a ModdedStringOption."); + } + + /// + public override void SetValue(object value) + { + var opt = HolderOption as ModdedStringOption; + opt?.SetValue((string)value); + } +} diff --git a/MiraAPI/GameOptions/Attributes/OptionList/ModdedEnumOptionListAttribute.cs b/MiraAPI/GameOptions/Attributes/OptionList/ModdedEnumOptionListAttribute.cs index 2104860..ec8d09f 100644 --- a/MiraAPI/GameOptions/Attributes/OptionList/ModdedEnumOptionListAttribute.cs +++ b/MiraAPI/GameOptions/Attributes/OptionList/ModdedEnumOptionListAttribute.cs @@ -8,6 +8,9 @@ namespace MiraAPI.GameOptions.Attributes; /// /// Attribute for creating a list of enum options. /// +/// The title of the option. +/// The type. +/// An option list of string values to use in place of the name. [AttributeUsage(AttributeTargets.Property)] public class ModdedEnumOptionListAttribute(string title, Type enumType, string[]? values = null) : ModdedOptionListAttribute(title) @@ -39,10 +42,12 @@ public class ModdedEnumOptionListAttribute(string title, Type enumType, string[] /// Attribute for creating a list of enum options. /// /// The enum type. +/// The option's title. +/// The option's possible values. [AttributeUsage(AttributeTargets.Property)] public class ModdedEnumOptionListAttribute(string title, string[]? values = null) : ModdedOptionListAttribute(title) - where T : Enum + where T : struct, Enum { internal override IModdedOptionList CreateOptionList(IList value, PropertyInfo property) { diff --git a/MiraAPI/GameOptions/Attributes/OptionList/ModdedNumberOptionListAttribute.cs b/MiraAPI/GameOptions/Attributes/OptionList/ModdedNumberOptionListAttribute.cs index 80fdeda..d9eec55 100644 --- a/MiraAPI/GameOptions/Attributes/OptionList/ModdedNumberOptionListAttribute.cs +++ b/MiraAPI/GameOptions/Attributes/OptionList/ModdedNumberOptionListAttribute.cs @@ -7,8 +7,15 @@ using MiraAPI.Utilities; namespace MiraAPI.GameOptions.Attributes; /// -/// A number option attribute for the modded options system. +/// Attribute for creating a list of number options. /// +/// The option title. +/// The minimum value. +/// The maximum value. +/// The increment. +/// The suffix type. +/// Optional format string for the option screen. +/// Flag indicating whether 0 means infinity. [AttributeUsage(AttributeTargets.Property)] public class ModdedNumberOptionListAttribute( string title, diff --git a/MiraAPI/GameOptions/Attributes/OptionList/ModdedStringOptionList.cs b/MiraAPI/GameOptions/Attributes/OptionList/ModdedStringOptionList.cs new file mode 100644 index 0000000..461ee54 --- /dev/null +++ b/MiraAPI/GameOptions/Attributes/OptionList/ModdedStringOptionList.cs @@ -0,0 +1,42 @@ +using System; +using System.Collections; +using System.Reflection; +using MiraAPI.GameOptions.OptionTypes; +using MiraAPI.Translation; + +namespace MiraAPI.GameOptions.Attributes; + +/// +/// Attribute for creating a list of string options. +/// +/// The title of the option. +/// An option list of string value. +[AttributeUsage(AttributeTargets.Property)] +public class ModdedStringOptionListAttribute( + string title, + string[] values) + : ModdedOptionListAttribute(title) +{ + internal override IModdedOptionList CreateOptionList(IList value, PropertyInfo property) + { + return new ModdedOptionList(value.Count, idx => + { + return new ModdedStringOption(Title.Translate(), (string)(value[idx] ?? string.Empty), values); + }); + } + + /// + public override void SetValue(int idx, object value) + { + var opt = HolderOptionList?[idx] as ModdedStringOption; + opt?.SetValue((string)value); + } + + /// + public override object GetValue(int idx) + { + return HolderOptionList?[idx] is ModdedStringOption opt + ? (object)opt.Value + : throw new InvalidOperationException($"HolderOption for option \"{GetFormattedTitle(idx)}\" is not a ModdedStringOption"); + } +} diff --git a/MiraAPI/GameOptions/IModdedOption.cs b/MiraAPI/GameOptions/IModdedOption.cs index fdf71a6..315fe2e 100644 --- a/MiraAPI/GameOptions/IModdedOption.cs +++ b/MiraAPI/GameOptions/IModdedOption.cs @@ -57,6 +57,21 @@ public interface IModdedOption /// Func Visible { get; set; } + /// + /// Gets or sets a getter event for the option, allowing for dynamic values based on external circumstances. + /// + Delegate? Getter { get; set; } + + /// + /// Gets or sets a setter event for the option, allowing for dynamic value changes based on external circumstances. + /// + Delegate? Setter { get; set; } + + /// + /// Gets or sets the event that is invoked when the value of the option changes. + /// + Delegate? ChangedEvent { get; set; } + /// /// Gets or sets a value indicating whether the option should be included with presets. /// @@ -114,6 +129,12 @@ public interface IModdedOption /// /// The representing the preset configuration. void LoadFromPreset(ConfigFile presetConfig); + + /// + /// Gets the type of the value that the option represents. + /// + /// The type of the option value. + Type GetValueType(); } /// diff --git a/MiraAPI/GameOptions/ModdedOptionsManager.cs b/MiraAPI/GameOptions/ModdedOptionsManager.cs index b893040..9959832 100644 --- a/MiraAPI/GameOptions/ModdedOptionsManager.cs +++ b/MiraAPI/GameOptions/ModdedOptionsManager.cs @@ -26,6 +26,7 @@ public static class ModdedOptionsManager { private static readonly Dictionary OptionAttributes = []; private static readonly Dictionary TypeToGroup = []; + private static readonly HashSet PatchedListTypes = []; internal static readonly Dictionary CreatedPlayerOptions = []; internal static readonly Dictionary CreatedStringOptions = []; @@ -181,6 +182,27 @@ public static class ModdedOptionsManager option.Visible = visibilityFunc; } + var getterAtr = property.GetCustomAttribute(); + var getterFunc = getterAtr?.GetGetter(group, property, option); + if (getterFunc != null) + { + option.Getter = getterFunc; + } + + var setterAtr = property.GetCustomAttribute(); + var setterFunc = setterAtr?.GetSetter(group, property, option); + if (setterFunc != null) + { + option.Setter = setterFunc; + } + + var changedAtr = property.GetCustomAttribute(); + var changedFunc = changedAtr?.GetChanged(group, property, option); + if (changedFunc != null) + { + option.ChangedEvent = changedFunc; + } + RegisterOption(option, group, property.Name, pluginInfo); } @@ -255,28 +277,61 @@ public static class ModdedOptionsManager var getterPatch = typeof(ModdedOptionsManager).GetMethod(nameof(PropertyGetterPatch)); PluginSingleton.Instance.Harmony.Patch(getterOriginal, prefix: new HarmonyMethod(getterPatch)); - var listIndex = propertyList.GetType().GetProperty("Item")!; + var listType = propertyList.GetType(); - var listSetterOriginal = listIndex.GetSetMethod(); - var listSetterPatch = typeof(ModdedOptionsManager).GetMethod(nameof(PropertyListSetterPatch)); - PluginSingleton.Instance.Harmony.Patch(listSetterOriginal, postfix: new HarmonyMethod(listSetterPatch)); + if (PatchedListTypes.Add(listType)) + { + var listIndex = listType.GetProperty("Item")!; - var listGetterOriginal = listIndex.GetSetMethod(); - var listGetterPatch = typeof(ModdedOptionsManager).GetMethod(nameof(PropertyListGetterPatch)); - PluginSingleton.Instance.Harmony.Patch(listGetterOriginal, prefix: new HarmonyMethod(listGetterPatch)); + var listSetterOriginal = listIndex.GetSetMethod(); + var listSetterPatch = typeof(ModdedOptionsManager).GetMethod(nameof(PropertyListSetterPatch)); + PluginSingleton.Instance.Harmony.Patch(listSetterOriginal, postfix: new HarmonyMethod(listSetterPatch)); + + var listGetterOriginal = listIndex.GetGetMethod(); + var listGetterPatch = typeof(ModdedOptionsManager).GetMethod(nameof(PropertyListGetterPatch)); + PluginSingleton.Instance.Harmony.Patch(listGetterOriginal, prefix: new HarmonyMethod(listGetterPatch)); + } OptionAttributes.Add(property, attribute); attribute.HolderOptionList = optionList; attribute.Value = propertyVal; + var valueType = property.PropertyType.GetGenericArguments()[0]; + var visibilityAttr = property.GetCustomAttribute(); - var visibilityFunc = visibilityAttr?.GetListVisibility(group, property); - for (int i = 0; i < optionList.Count; i++) + var visibilityFactory = visibilityAttr?.GetListVisibility(group, property); + + var getterAttr = property.GetCustomAttribute(); + var getterFactory = getterAttr?.GetListGetter(group, property, valueType); + + var setterAttr = property.GetCustomAttribute(); + var setterFactory = setterAttr?.GetListSetter(group, property, valueType); + + var changedAttr = property.GetCustomAttribute(); + var changedFactory = changedAttr?.GetListChanged(group, property, valueType); + + for (var i = 0; i < optionList.Count; i++) { var option = optionList[i]; - if (visibilityFunc != null) + + if (visibilityFactory != null) { - option.Visible = () => visibilityFunc(i); + option.Visible = (Func)visibilityFactory(i); + } + + if (getterFactory != null) + { + option.Getter = getterFactory(i); + } + + if (setterFactory != null) + { + option.Setter = setterFactory(i); + } + + if (changedFactory != null) + { + option.ChangedEvent = changedFactory(i); } RegisterOption(option, group, property.Name + (i + 1), pluginInfo); @@ -350,7 +405,7 @@ public static class ModdedOptionsManager /// The original setter method. /// The new object value. [SuppressMessage("ReSharper", "InconsistentNaming", Justification = "Harmony naming convention.")] - [SuppressMessage("Naming", "CA1707:Identifiers should not contain underscores", Justification = "Harmony naming convention.")] + [SuppressMessage("Naming", "CA1707:Identifiers should not contain underscores", Justification = "Read above.")] public static void PropertySetterPatch(MethodBase __originalMethod, object value) { var attribute = OptionAttributes.First(pair => pair.Key.GetSetMethod() == __originalMethod).Value; @@ -364,7 +419,7 @@ public static class ModdedOptionsManager /// The result of the property getter. /// so the original getter gets skipped. [SuppressMessage("ReSharper", "InconsistentNaming", Justification = "Harmony naming convention")] - [SuppressMessage("Naming", "CA1707:Identifiers should not contain underscores", Justification = "Harmony naming convention.")] + [SuppressMessage("Naming", "CA1707:Identifiers should not contain underscores", Justification = "Read above.")] public static bool PropertyGetterPatch(MethodBase __originalMethod, ref object __result) { var attribute = OptionAttributes.First(pair => pair.Key.GetGetMethod() == __originalMethod).Value; @@ -382,9 +437,10 @@ public static class ModdedOptionsManager [SuppressMessage("Naming", "CA1707:Identifiers should not contain underscores", Justification = "Read above.")] public static void PropertyListSetterPatch(object __instance, int index, object value) { - var attribute = (ModdedOptionListAttribute)OptionAttributes.First( - pair => pair.Value is ModdedOptionListAttribute list && ReferenceEquals(list.Value, __instance)).Value; - attribute.SetValue(index, value); + var match = OptionAttributes.Values + .OfType() + .FirstOrDefault(list => ReferenceEquals(list.Value, __instance)); + match?.SetValue(index, value); } /// @@ -398,9 +454,14 @@ public static class ModdedOptionsManager [SuppressMessage("Naming", "CA1707:Identifiers should not contain underscores", Justification = "Read above.")] public static bool PropertyListGetterPatch(object __instance, int index, ref object __result) { - var attribute = (ModdedOptionListAttribute)OptionAttributes.First( - pair => pair.Value is ModdedOptionListAttribute list && ReferenceEquals(list.Value, __instance)).Value; - __result = attribute.GetValue(index); + var match = OptionAttributes.Values + .OfType() + .FirstOrDefault(list => ReferenceEquals(list.Value, __instance)); + + if (match is null) + return true; + + __result = match.GetValue(index); return false; } } diff --git a/MiraAPI/GameOptions/OptionTypes/ModdedEnumOption.cs b/MiraAPI/GameOptions/OptionTypes/ModdedEnumOption.cs index 69815b9..764df9c 100644 --- a/MiraAPI/GameOptions/OptionTypes/ModdedEnumOption.cs +++ b/MiraAPI/GameOptions/OptionTypes/ModdedEnumOption.cs @@ -113,7 +113,7 @@ public class ModdedEnumOption : ModdedOption /// /// The type. public class ModdedEnumOption : ModdedOption - where T : Enum + where T : struct, Enum { /// /// Gets the string values of the . @@ -130,7 +130,7 @@ public class ModdedEnumOption : ModdedOption public ModdedEnumOption(string title, T defaultValue, string[]? values = null, bool includeInPreset = true) : base(title, defaultValue, includeInPreset) { - Values = values ?? Enum.GetNames(typeof(T)); + Values = values ?? Enum.GetNames(); var data = ScriptableObject.CreateInstance(); data.Title = StringName; @@ -151,6 +151,10 @@ public class ModdedEnumOption : ModdedOption stringOption.SetUpFromData(Data, 20); stringOption.OnValueChanged = (Il2CppSystem.Action)ValueChanged; + // Changing the button text to make them more distinct from number options + stringOption.MinusBtn.ChangeButtonText("<"); + stringOption.PlusBtn.ChangeButtonText(">"); + // SetUpFromData method doesn't work correctly so we must set the values manually stringOption.Title = StringName; stringOption.Values = (Data as StringGameSetting)?.Values ?? new Il2CppStructArray(0); @@ -176,13 +180,13 @@ public class ModdedEnumOption : ModdedOption /// public override void HandleNetData(byte[] data) { - SetValue((T)Enum.Parse(typeof(T), Encoding.Unicode.GetString(data))); + SetValue(Enum.Parse(Encoding.Unicode.GetString(data))); } /// public override T GetValueFromOptionBehaviour(OptionBehaviour optionBehaviour) { - return (T)Enum.Parse(typeof(T), optionBehaviour.GetInt().ToString(NumberFormatInfo.InvariantInfo)); + return Enum.Parse(optionBehaviour.GetInt().ToString(NumberFormatInfo.InvariantInfo)); } /// diff --git a/MiraAPI/GameOptions/OptionTypes/ModdedOption.cs b/MiraAPI/GameOptions/OptionTypes/ModdedOption.cs index 24747ad..2342fc9 100644 --- a/MiraAPI/GameOptions/OptionTypes/ModdedOption.cs +++ b/MiraAPI/GameOptions/OptionTypes/ModdedOption.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using BepInEx.Configuration; using MiraAPI.Networking; using MiraAPI.PluginLoading; @@ -43,16 +44,32 @@ public abstract class ModdedOption : IModdedOption /// /// Gets or sets the value of the option. /// - public T Value { get; protected set; } + public T Value + { + get => Getter != null ? Getter(_value) : _value; + protected set + { + if (Setter != null) + value = Setter(value, _value); + + _value = value; + } + } + + private T _value; /// /// Gets the default value of the option. /// public T DefaultValue { get; } - /// - /// Gets or sets the event that is invoked when the value of the option changes. - /// + /// + public Func? Getter { get; set; } + + /// + public Func? Setter { get; set; } + + /// public Action? ChangedEvent { get; set; } /// @@ -67,6 +84,33 @@ public abstract class ModdedOption : IModdedOption /// public ConfigDefinition? ConfigDefinition { get; set; } + /// + public AbstractOptionGroup ParentGroup { get; set; } + + /// + public virtual OptionNotifConfiguration Configuration => new(ParentGroup); + + /// + Delegate? IModdedOption.Getter + { + get => Getter; + set => Getter = value as Func; + } + + /// + Delegate? IModdedOption.Setter + { + get => Setter; + set => Setter = value as Func; + } + + /// + Delegate? IModdedOption.ChangedEvent + { + get => ChangedEvent; + set => ChangedEvent = value as Action; + } + /// /// Initializes a new instance of the class. /// @@ -96,25 +140,25 @@ public abstract class ModdedOption : IModdedOption /// Whether to send the value to other players. public void SetValue(T newValue, bool sendRpc = true) { - var oldVal = Value; + var oldVal = _value; Value = newValue; - if (Value?.Equals(oldVal) == false) + if (!EqualityComparer.Default.Equals(_value, oldVal)) { - ChangedEvent?.Invoke(Value); + ChangedEvent?.Invoke(_value); } if (sendRpc && AmongUsClient.Instance.AmHost) { if (ParentMod?.GetConfigFile().TryGetEntry(ConfigDefinition, out var entry) == true) { - entry.Value = Value; + entry.Value = _value; } Rpc.Instance.Send(PlayerControl.LocalPlayer, [GetNetData()], true); } - OnValueChanged(newValue); + OnValueChanged(_value); } /// @@ -149,6 +193,12 @@ public abstract class ModdedOption : IModdedOption } } + /// + public Type GetValueType() + { + return typeof(T); + } + /// public abstract float GetFloatData(); @@ -188,10 +238,4 @@ public abstract class ModdedOption : IModdedOption { return option.Value; } - - /// - public AbstractOptionGroup ParentGroup { get; set; } - - /// - public virtual OptionNotifConfiguration Configuration => new(ParentGroup); } diff --git a/MiraAPI/GameOptions/OptionTypes/ModdedOptionList.cs b/MiraAPI/GameOptions/OptionTypes/ModdedOptionList.cs index 257ae6e..a9f5143 100644 --- a/MiraAPI/GameOptions/OptionTypes/ModdedOptionList.cs +++ b/MiraAPI/GameOptions/OptionTypes/ModdedOptionList.cs @@ -50,11 +50,13 @@ public class ModdedOptionList : IModdedOptionList return Options.GetEnumerator(); } + /// IEnumerator IEnumerable.GetEnumerator() { return Options.GetEnumerator(); } + /// IEnumerator IEnumerable.GetEnumerator() { return ((IEnumerable)Options).GetEnumerator(); @@ -67,5 +69,6 @@ public class ModdedOptionList : IModdedOptionList /// option of type . public T this[int idx] => Options[idx]; + /// IModdedOption IReadOnlyList.this[int index] => this[index]; } diff --git a/MiraAPI/GameOptions/OptionTypes/ModdedStringOption.cs b/MiraAPI/GameOptions/OptionTypes/ModdedStringOption.cs index 8381177..6209881 100644 --- a/MiraAPI/GameOptions/OptionTypes/ModdedStringOption.cs +++ b/MiraAPI/GameOptions/OptionTypes/ModdedStringOption.cs @@ -25,6 +25,28 @@ public class ModdedStringOption : ModdedOption /// public StringNames[] StringNameValues { get; set; } + /// + /// Initializes a new instance of the class. + /// + /// The title of the option. + /// The default value as a string. + /// An option list of string values to use. + /// Whether to include this option in the preset or not. + public ModdedStringOption(string title, string defaultValue, string[] values, bool includeInPreset = true) + : base(title, defaultValue, includeInPreset) + { + Values = values; + Data = ScriptableObject.CreateInstance(); + var data = (StringGameSetting)Data; + + data.Title = StringName; + data.Type = global::OptionTypes.String; + StringNameValues = [.. values.Select(MiraLocaleManager.GetOrCreateLocaleString)]; + data.Values = StringNameValues; + + data.Index = GetIndex(Value); + } + /// /// Gets the string's index. /// @@ -61,61 +83,37 @@ public class ModdedStringOption : ModdedOption StringNameValues = [.. newStrVals]; } - if (Data != null) - { - var data = (StringGameSetting)Data; - data.Values = StringNameValues; - - data.Index = GetIndex(Value); - if (OptionBehaviour != null) - { - var strOpt = OptionBehaviour.TryCast(); - if (strOpt != null) - { - strOpt.Values = StringNameValues; - strOpt.Value = GetIndex(Value); - Warning("StringOption is valid"); - } - else - { - Error("StringOption is null."); - } - - Warning("OptionBehaviour is valid"); - } - else - { - Error("OptionBehaviour is null."); - } - - Warning("Data is valid"); - } - else + if (Data == null) { Error("Data is null."); + return; } - } - /// - /// Initializes a new instance of the class. - /// - /// The title of the option. - /// The default value as a string. - /// An option list of string values to use. - /// Whether to include this option in the preset or not. - public ModdedStringOption(string title, string defaultValue, string[] values, bool includeInPreset = true) - : base(title, defaultValue, includeInPreset) - { - Values = values; - Data = ScriptableObject.CreateInstance(); var data = (StringGameSetting)Data; - - data.Title = StringName; - data.Type = global::OptionTypes.String; - StringNameValues = [.. values.Select(MiraLocaleManager.GetOrCreateLocaleString)]; data.Values = StringNameValues; - data.Index = GetIndex(Value); + + Warning("Data is valid"); + + if (OptionBehaviour == null) + { + Error("OptionBehaviour is null."); + return; + } + + var strOpt = OptionBehaviour.TryCast(); + + Warning("OptionBehaviour is valid"); + + if (strOpt == null) + { + Error("StringOption is null."); + return; + } + + strOpt.Values = StringNameValues; + strOpt.Value = GetIndex(Value); + Warning("StringOption is valid"); } /// @@ -128,6 +126,10 @@ public class ModdedStringOption : ModdedOption stringOption.SetUpFromData(Data, 20); stringOption.OnValueChanged = (Il2CppSystem.Action)ValueChanged; + // Changing the button text to make them more distinct from number options + stringOption.MinusBtn.ChangeButtonText("<"); + stringOption.PlusBtn.ChangeButtonText(">"); + // SetUpFromData method doesn't work correctly so we must set the values manually stringOption.Title = StringName; stringOption.Values = (Data as StringGameSetting)?.Values ?? new Il2CppStructArray(0); diff --git a/MiraAPI/MiraApiPlugin.cs b/MiraAPI/MiraApiPlugin.cs index a07c36d..3aa3bb5 100644 --- a/MiraAPI/MiraApiPlugin.cs +++ b/MiraAPI/MiraApiPlugin.cs @@ -67,6 +67,7 @@ public partial class MiraApiPlugin : BasePlugin, IMiraPlugin internal static ConfigEntry WikiSortingGroup { get; private set; } internal static ConfigEntry WikiSortingMethod { get; private set; } internal static ConfigEntry WikiSortingFilter { get; private set; } + internal static ConfigEntry DistanceSuffix { get; private set; } /// public override void Load() @@ -86,6 +87,11 @@ public partial class MiraApiPlugin : BasePlugin, IMiraPlugin "WikiSortingFilter", SortingFilter.AllRoles, "Determines what roles / modifiers are displayed at once."); + DistanceSuffix = Config.Bind( + "Display", + "Distance", + "unit(s)", + "Dictates the unit of distance used in numeric option displays representing a distance."); Harmony.PatchAll(); RoleOptionsCollectionPatch.PatchRoleMethods(Harmony); UiResetEvents.Initialize(); diff --git a/MiraAPI/Patches/Options/SettingPatches.cs b/MiraAPI/Patches/Options/SettingPatches.cs index 59b956c..3300a02 100644 --- a/MiraAPI/Patches/Options/SettingPatches.cs +++ b/MiraAPI/Patches/Options/SettingPatches.cs @@ -51,6 +51,7 @@ public static class SettingPatches MiraNumberSuffixes.None => value.ToString(__instance.FormatString, NumberFormatInfo.InvariantInfo), MiraNumberSuffixes.Multiplier => value.ToString(__instance.FormatString, NumberFormatInfo.InvariantInfo) + "x", MiraNumberSuffixes.Percent => value.ToString(__instance.FormatString, NumberFormatInfo.InvariantInfo) + "%", + MiraNumberSuffixes.Distance => value.ToString(__instance.FormatString, NumberFormatInfo.InvariantInfo) + MiraApiPlugin.DistanceSuffix.Value, _ => TranslationController.Instance.GetString( StringNames.GameSecondsAbbrev, (Il2CppSystem.Object[])[value.ToString(__instance.FormatString, CultureInfo.InvariantCulture)]), @@ -68,6 +69,7 @@ public static class SettingPatches MiraNumberSuffixes.None => value.ToString(__instance.FormatString, NumberFormatInfo.InvariantInfo), MiraNumberSuffixes.Multiplier => value.ToString(__instance.FormatString, NumberFormatInfo.InvariantInfo) + "x", MiraNumberSuffixes.Percent => value.ToString(__instance.FormatString, NumberFormatInfo.InvariantInfo) + "%", + MiraNumberSuffixes.Distance => value.ToString(__instance.FormatString, NumberFormatInfo.InvariantInfo) + MiraApiPlugin.DistanceSuffix.Value, _ => TranslationController.Instance.GetString( StringNames.GameSecondsAbbrev, (Il2CppSystem.Object[])[value.ToString(__instance.FormatString, CultureInfo.InvariantCulture)]), diff --git a/MiraAPI/Presets/OptionPreset.cs b/MiraAPI/Presets/OptionPreset.cs index 077afc9..6ecab1f 100644 --- a/MiraAPI/Presets/OptionPreset.cs +++ b/MiraAPI/Presets/OptionPreset.cs @@ -10,9 +10,6 @@ namespace MiraAPI.Presets; /// /// Represents a preset of game options that can be applied to the game. /// -/// -/// Initializes a new instance of the class with the specified name and configuration file. -/// /// The name of the preset. /// The plugin associated with the preset. /// The configuration file for the preset. diff --git a/MiraAPI/Utilities/MiraNumberSuffixes.cs b/MiraAPI/Utilities/MiraNumberSuffixes.cs index 2f55226..81274e9 100644 --- a/MiraAPI/Utilities/MiraNumberSuffixes.cs +++ b/MiraAPI/Utilities/MiraNumberSuffixes.cs @@ -24,4 +24,9 @@ public enum MiraNumberSuffixes /// Percentage suffix (%). /// Percent, + + /// + /// Distance suffix (m). + /// + Distance, }