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/.editorconfig b/MiraAPI.Example/.editorconfig index 171e612..7a18861 100644 --- a/MiraAPI.Example/.editorconfig +++ b/MiraAPI.Example/.editorconfig @@ -2,3 +2,6 @@ # SA1602: Enumeration items should be documented. dotnet_diagnostic.SA1602.severity = none + +# SA1601: Partial elements should be documented +dotnet_diagnostic.SA1601.severity = none diff --git a/MiraAPI.Example/Options/ExampleOptions.cs b/MiraAPI.Example/Options/ExampleOptions.cs index 1effde1..9b424c0 100644 --- a/MiraAPI.Example/Options/ExampleOptions.cs +++ b/MiraAPI.Example/Options/ExampleOptions.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using MiraAPI.GameOptions; using MiraAPI.GameOptions.Attributes; using MiraAPI.Utilities; @@ -6,6 +6,9 @@ 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"; @@ -13,27 +16,64 @@ public class ExampleOptions : AbstractOptionGroup public override bool ShowInMatchInfoGuide => true; [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; + [ModdedNumberOption("options.exampleOptions1.numberOpt", min: 0, max: 10, suffixType: MiraNumberSuffixes.Distance)] + public int NumberOpt2 { get; set; } = 4; // Can also be int! + + [ModdedEnumOption("options.exampleOptions1.bestApi", typeof(Api), ["Mira API", "Mitochondria", "Reactor"])] + public Api BestApi { get; set; } = Api.MiraAPI; + + [ModdedEnumOption("options.exampleOptions1.worstApi", ["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 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 ModdedArray ValueStates { get; set; } = [ValueState.Primary, ValueState.Secondary, ValueState.None, ValueState.None, ValueState.None]; + + [ModdedPlayerOption("options.exampleOptions1.players")] + public NetworkedPlayerInfo PlayerSelected { get; set; } + + 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 58885f5..e0d4aaa 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 override bool ShowInMatchInfoGuide => true; @@ -28,6 +26,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.Example/Resources/Locale/en_US.xml b/MiraAPI.Example/Resources/Locale/en_US.xml index 8328c69..b37cad6 100644 --- a/MiraAPI.Example/Resources/Locale/en_US.xml +++ b/MiraAPI.Example/Resources/Locale/en_US.xml @@ -47,6 +47,8 @@ Eliminate every other faction. Toggle Opt 2 Number Opt Best API + Worst API + Choose A Player Example Options 2 YOU MUST CHOSE A PLAYER Eh whatever diff --git a/MiraAPI/GameOptions/AbstractOptionGroup.cs b/MiraAPI/GameOptions/AbstractOptionGroup.cs index 52d8fdf..589f705 100644 --- a/MiraAPI/GameOptions/AbstractOptionGroup.cs +++ b/MiraAPI/GameOptions/AbstractOptionGroup.cs @@ -6,6 +6,7 @@ using System.Linq; using Il2CppInterop.Runtime.Attributes; using MiraAPI.Modifiers; using MiraAPI.Roles; +using MiraAPI.Utilities; using MiraAPI.Utilities.Assets; using UnityEngine; @@ -55,7 +56,7 @@ public abstract class AbstractOptionGroup /// /// Gets the function that determines whether the group should be visible or not. /// - public virtual Func GroupVisible => () => true; + public virtual Func GroupVisible => DelegateUtilities.AlwaysTrue; /// /// Gets a value indicating whether this group's options are shown in the Match Info Guide. diff --git a/MiraAPI/GameOptions/Attributes/Delegate/ModdedOptionChangedAttribute.cs b/MiraAPI/GameOptions/Attributes/Delegate/ModdedOptionChangedAttribute.cs new file mode 100644 index 0000000..f50dcda --- /dev/null +++ b/MiraAPI/GameOptions/Attributes/Delegate/ModdedOptionChangedAttribute.cs @@ -0,0 +1,40 @@ +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 var type, out var instanceGetter) is not { } method) + return null; + + var valueType = option.GetValueType(); + + if (!ValidateSignature(method, typeof(void), valueType)) + 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 var type, out var instanceGetter) is not { } method) + return null; + + if (!ValidateListSignature(method, typeof(void), out var hasIndex, valueType)) + 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..6a31bf8 --- /dev/null +++ b/MiraAPI/GameOptions/Attributes/Delegate/ModdedOptionGetterAttribute.cs @@ -0,0 +1,40 @@ +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 var type, out var instanceGetter) is not { } method) + return null; + + var valueType = option.GetValueType(); + + if (!ValidateSignature(method, valueType, valueType)) + 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 var type, out var instanceGetter) is not { } method) + return null; + + if (!ValidateListSignature(method, valueType, out var hasIndex, valueType)) + 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..2e1826c --- /dev/null +++ b/MiraAPI/GameOptions/Attributes/Delegate/ModdedOptionMemberAttribute.cs @@ -0,0 +1,366 @@ +using System; +using System.Collections.Generic; +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 +{ + private static readonly Dictionary DelegateCache = []; + + /// + /// A helper class to bind a specific list index to a dynamically compiled delegate. + /// + /// The index to bind. + public sealed class DelegateIndexClosure(int index) + { + /// + /// The bound index. + /// + [SuppressMessage("Design", "CA1051:Do not declare visible instance fields", Justification = "No need to be private.")] + [SuppressMessage("StyleCop.CSharp.MaintainabilityRules", "SA1401:Fields should be private", Justification = "Read above.")] + public readonly int Index = index; + } + + /// + /// Exposes the index field so child attributes can emit instructions to read it. + /// + protected static readonly FieldInfo IndexClosureField = typeof(DelegateIndexClosure).GetField(nameof(DelegateIndexClosure.Index))!; + + /// + /// Gets the resolved name of the target member. + /// + protected string? MemberName { get; private set; } = memberName; + + /// + /// Validates that a method matches the expected return type and parameter types. + /// + /// The method to validate. + /// The expected return type of the method. + /// The expected types of the parameters of the method. + /// true if the method is valid; false otherwise. + protected bool ValidateSignature(MethodInfo method, Type expectedReturnType, params Type[] expectedParamTypes) + { + if (method.ReturnType != expectedReturnType) + { + Error($"{MemberName}'s return type must be {expectedReturnType.Name}."); + return false; + } + + var parameters = method.GetParameters(); + + if (parameters.Length != expectedParamTypes.Length) + { + Error($"{MemberName} must have exactly {expectedParamTypes.Length} parameter(s)."); + return false; + } + + for (var i = 0; i < expectedParamTypes.Length; i++) + { + if (parameters[i].ParameterType == expectedParamTypes[i]) + continue; + + Error($"{MemberName}'s parameter at index {i} must be {expectedParamTypes[i].Name}."); + return false; + } + + return true; + } + + /// + /// Validates that a list method matches the expected return type, base parameter types, and an optional integer index. + /// + /// The method to validate. + /// The expected return type of the method. + /// When the method returns, a flag that indicates if the method has an index parameter. + /// The expected types of the parameters of the method. + /// true if the method is valid; false otherwise. + protected bool ValidateListSignature(MethodInfo method, Type expectedReturnType, out bool hasIndex, params Type[] expectedParamTypes) + { + hasIndex = false; + + if (method.ReturnType != expectedReturnType) + { + Error($"{MemberName}'s return type must be {expectedReturnType.Name}."); + return false; + } + + var parameters = method.GetParameters(); + + if (parameters.Length < expectedParamTypes.Length || parameters.Length > expectedParamTypes.Length + 1) + { + Error($"{MemberName} must have {expectedParamTypes.Length} parameter(s), optionally with an extra trailing int parameter."); + return false; + } + + for (var i = 0; i < expectedParamTypes.Length; i++) + { + if (parameters[i].ParameterType == expectedParamTypes[i]) + continue; + + Error($"{MemberName}'s parameter at index {i} must be {expectedParamTypes[i].Name}."); + return false; + } + + if (parameters.Length == expectedParamTypes.Length + 1) + { + if (parameters[^1].ParameterType != typeof(int)) + { + Error($"{MemberName}'s final parameter must be an int (the list index)."); + return false; + } + + hasIndex = true; + } + + return true; + } + + /// + /// 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) + return member; + + bool isStatic; + + if (member is FieldInfo field) + { + isStatic = field.IsStatic; + } + else + { + var method = (member as PropertyInfo)?.GetGetMethod(true) ?? member as MethodInfo; + isStatic = method is { IsStatic: true }; + } + + if (isStatic) + 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) + { + if (postProcessCall == null && DelegateCache.TryGetValue(method, out var cachedDelegate)) + return cachedDelegate; + + 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++) + { + var loadOp = i switch + { + 0 => OpCodes.Ldarg_0, + 1 => OpCodes.Ldarg_1, + 2 => OpCodes.Ldarg_2, + 3 => OpCodes.Ldarg_3, + _ => OpCodes.Ldarg_S, + }; + + if (loadOp == OpCodes.Ldarg_S) + il.Emit(loadOp, i); + else + il.Emit(loadOp); + } + } + + var callOp = method.IsStatic ? OpCodes.Call : OpCodes.Callvirt; + il.Emit(callOp, method); + + postProcessCall?.Invoke(il); + + il.Emit(OpCodes.Ret); + + var compiledDelegate = dm.CreateDelegate(delegateType); + + if (postProcessCall == null) + DelegateCache[method] = compiledDelegate; + + return compiledDelegate; + } + + /// + /// 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, + baseParamTypes.Length > 0, + postProcessCall); + + return _ => sharedDelegate; + } + + var fullParamTypes = new Type[baseParamTypes.Length + 1]; + fullParamTypes[0] = typeof(DelegateIndexClosure); + Array.Copy(baseParamTypes, 0, fullParamTypes, 1, baseParamTypes.Length); + + var dm = new DynamicMethod($"{ownerType.Name}_{method.Name}_{suffix}_List", returnType, fullParamTypes, ownerType, true); + var il = dm.GetILGenerator(); + + if (!method.IsStatic && instanceGetter != null) + il.Emit(OpCodes.Call, instanceGetter); + + for (var i = 0; i < baseParamTypes.Length; i++) + { + var argPos = (byte)(i + 1); + var loadOp = argPos switch + { + 1 => OpCodes.Ldarg_1, + 2 => OpCodes.Ldarg_2, + 3 => OpCodes.Ldarg_3, + _ => OpCodes.Ldarg_S, + }; + + if (loadOp == OpCodes.Ldarg_S) + il.Emit(loadOp, argPos); + else + il.Emit(loadOp); + } + + if (hasIndexParameter) + { + il.Emit(OpCodes.Ldarg_0); + il.Emit(OpCodes.Ldfld, IndexClosureField); + } + + var callOp = method.IsStatic ? OpCodes.Call : OpCodes.Callvirt; + il.Emit(callOp, method); + + postProcessCall?.Invoke(il); + postProcessListCall?.Invoke(il); + + il.Emit(OpCodes.Ret); + + return index => dm.CreateDelegate(delegateType, new DelegateIndexClosure(index)); + } +} diff --git a/MiraAPI/GameOptions/Attributes/Delegate/ModdedOptionSetterAttribute.cs b/MiraAPI/GameOptions/Attributes/Delegate/ModdedOptionSetterAttribute.cs new file mode 100644 index 0000000..34cb2c1 --- /dev/null +++ b/MiraAPI/GameOptions/Attributes/Delegate/ModdedOptionSetterAttribute.cs @@ -0,0 +1,40 @@ +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 var type, out var instanceGetter) is not { } method) + return null; + + var valueType = option.GetValueType(); + + if (!ValidateSignature(method, valueType, valueType, valueType)) + 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 var type, out var instanceGetter) is not { } method) + return null; + + if (!ValidateListSignature(method, valueType, out var hasIndex, valueType, valueType)) + 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..6929cbf --- /dev/null +++ b/MiraAPI/GameOptions/Attributes/Delegate/ModdedOptionVisibilityAttribute.cs @@ -0,0 +1,253 @@ +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 if (member is FieldInfo field) + { + if (field.FieldType != typeof(bool)) + { + Error($"Field {MemberName} is not a bool."); + return null; + } + + var dynamicMethod = new DynamicMethod($"GetVisibility_{type.Name}_{field.Name}", typeof(bool), Type.EmptyTypes, type, true); + var il = dynamicMethod.GetILGenerator(); + + if (instanceGetter != null) + { + il.Emit(OpCodes.Ldarg_0); + il.Emit(OpCodes.Call, instanceGetter); + il.Emit(OpCodes.Ldfld, field); + } + else + { + il.Emit(OpCodes.Ldsfld, field); + } + + il.Emit(OpCodes.Ret); + return dynamicMethod.CreateDelegate>(); + } + 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 => + { + il.Emit(OpCodes.Ldarg_0); + il.Emit(OpCodes.Ldfld, IndexClosureField); + 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 if (member is FieldInfo field) + { + if (field.FieldType.IsAssignableTo(typeof(IList))) + { + postProcess = il => + { + il.Emit(OpCodes.Ldarg_0); + il.Emit(OpCodes.Callvirt, ListBoolIndexerGetter); + }; + } + else if (field.FieldType != typeof(bool)) + { + Error($"Field {MemberName} is not a bool or a list of bools."); + return null; + } + + var dynamicMethod = new DynamicMethod($"GetListVisibility_{type.Name}_{field.Name}", typeof(bool), [typeof(int)], type, true); + var il = dynamicMethod.GetILGenerator(); + + if (instanceGetter != null) + { + il.Emit(OpCodes.Ldarg_0); + il.Emit(OpCodes.Call, instanceGetter); + il.Emit(OpCodes.Ldfld, field); + } + else + { + il.Emit(OpCodes.Ldsfld, field); + } + + postProcess?.Invoke(il); + + il.Emit(OpCodes.Ret); + + var func = dynamicMethod.CreateDelegate>(); + return index => () => func(index); + } + 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..2754920 100644 --- a/MiraAPI/GameOptions/Attributes/ModdedEnumOptionAttribute.cs +++ b/MiraAPI/GameOptions/Attributes/ModdedEnumOptionAttribute.cs @@ -1,7 +1,6 @@ using System; using System.Reflection; using MiraAPI.GameOptions.OptionTypes; -using MiraAPI.Translation; namespace MiraAPI.GameOptions.Attributes; @@ -17,7 +16,7 @@ public class ModdedEnumOptionAttribute(string title, Type enumType, string[]? va { internal override IModdedOption CreateOption(object? value, PropertyInfo property) { - var opt = new ModdedEnumOption(Title.Translate(), (int)(value ?? 0), enumType, values); + var opt = new ModdedEnumOption(Title, (int)(value ?? 0), enumType, values); return opt; } @@ -33,7 +32,7 @@ public class ModdedEnumOptionAttribute(string title, Type enumType, string[]? va { return HolderOption is ModdedEnumOption opt ? Enum.ToObject(enumType, opt.Value) - : throw new InvalidOperationException($"HolderOption for option \"{Title}\" with EnumType ${enumType.FullName} is not a ModdedEnumOption"); + : throw new InvalidOperationException($"HolderOption for option \"{Title}\" with EnumType {enumType.FullName} is not a ModdedEnumOption"); } } @@ -41,10 +40,12 @@ public class ModdedEnumOptionAttribute(string title, Type enumType, string[]? va /// Attribute for creating an enum option. /// /// The enum type. +/// The title of the option. +/// An option list of string values to use in place of the name. [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) { @@ -64,6 +65,6 @@ public class ModdedEnumOptionAttribute(string title, string[]? values = null) { return HolderOption is ModdedEnumOption opt ? opt.Value - : throw new InvalidOperationException($"HolderOption for option \"{Title}\" with EnumType ${typeof(T).FullName} is not a ModdedEnumOption"); + : throw new InvalidOperationException($"HolderOption for option \"{Title}\" with EnumType {typeof(T).FullName} is not a ModdedEnumOption<{typeof(T).Name}>"); } } diff --git a/MiraAPI/GameOptions/Attributes/ModdedNumberOptionAttribute.cs b/MiraAPI/GameOptions/Attributes/ModdedNumberOptionAttribute.cs index 1d9e7da..f43773e 100644 --- a/MiraAPI/GameOptions/Attributes/ModdedNumberOptionAttribute.cs +++ b/MiraAPI/GameOptions/Attributes/ModdedNumberOptionAttribute.cs @@ -1,7 +1,6 @@ using System; using System.Reflection; using MiraAPI.GameOptions.OptionTypes; -using MiraAPI.Translation; using MiraAPI.Utilities; namespace MiraAPI.GameOptions.Attributes; @@ -13,45 +12,93 @@ namespace MiraAPI.GameOptions.Attributes; /// The minimum value. /// The maximum value. /// The increment. +/// Determines what is shown for zero. +/// Determines what is shown for negative 1. /// The suffix type. /// Optional format string for the option screen. -/// Flag indicating whether 0 means infinity. +/// Whether increments can be split in half (using Shift). [AttributeUsage(AttributeTargets.Property)] public class ModdedNumberOptionAttribute( string title, float min, float max, - float increment = 1, + float increment, + string zeroBehavior, + string negativeBehavior, MiraNumberSuffixes suffixType = MiraNumberSuffixes.None, string? formatString = null, - bool zeroInfinity = false) + bool halfIncrements = false) : ModdedOptionAttribute(title) { + /// + /// Initializes a new instance of the class. + /// + /// The title of the option. + /// The minimum value. + /// The maximum value. + /// The increment. + /// The suffix type. + /// Whether zero is infinity or not. + /// Optional format string for the option screen. + public ModdedNumberOptionAttribute( + string title, + float min, + float max, + float increment = 1, + MiraNumberSuffixes suffixType = MiraNumberSuffixes.None, + string? formatString = null, + bool zeroInfinity = false) + : this(title, min, max, increment, zeroInfinity ? "∞" : "#", "#", suffixType, formatString, increment == 1f) + { + } + internal override IModdedOption CreateOption(object? value, PropertyInfo property) { + var final = value switch + { + null => min + increment, + int integer => integer, + _ => (float)value, + }; + return new ModdedNumberOption( - Title.Translate(), - (float)(value ?? min + increment), + Title, + final, min, max, increment, + zeroBehavior, + negativeBehavior, suffixType, formatString, - zeroInfinity); + halfIncrements); } /// public override void SetValue(object value) { - var opt = HolderOption as ModdedNumberOption; - opt?.SetValue((float)value); + if (HolderOption is not ModdedNumberOption opt) + return; + + var final = value switch + { + int integer => integer, + _ => (float)value, + }; + opt.SetValue(final); } /// public override object GetValue() { - return HolderOption is ModdedNumberOption opt - ? (object)opt.Value - : throw new InvalidOperationException($"HolderOption for option \"{Title}\" is not a ModdedNumberOption"); + if (HolderOption is not ModdedNumberOption opt) + throw new InvalidOperationException($"HolderOption for option \"{Title}\" is not a ModdedNumberOption"); + + var value = opt.Value; + + if (ValueType == typeof(int)) + return (int)Math.Round(value); + + return value; } } diff --git a/MiraAPI/GameOptions/Attributes/ModdedOptionAttribute.cs b/MiraAPI/GameOptions/Attributes/ModdedOptionAttribute.cs index 98194f8..91b7856 100644 --- a/MiraAPI/GameOptions/Attributes/ModdedOptionAttribute.cs +++ b/MiraAPI/GameOptions/Attributes/ModdedOptionAttribute.cs @@ -13,6 +13,7 @@ namespace MiraAPI.GameOptions.Attributes; public abstract class ModdedOptionAttribute(string title, Type? roleType = null, Type? modeType = null) : PropertyOptionAttribute { internal IModdedOption? HolderOption { get; set; } + internal Type ValueType { get; set; } /// /// Gets the title of the option. 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/ModdedPlayerOptionAttribute.cs b/MiraAPI/GameOptions/Attributes/ModdedPlayerOptionAttribute.cs new file mode 100644 index 0000000..610d0c8 --- /dev/null +++ b/MiraAPI/GameOptions/Attributes/ModdedPlayerOptionAttribute.cs @@ -0,0 +1,49 @@ +using System; +using System.Reflection; +using MiraAPI.GameOptions.OptionTypes; + +namespace MiraAPI.GameOptions.Attributes; + +/// +/// Attribute for a . +/// +/// The title of the option. +/// Whether the option includes a none option. If None is selected, value will return -1. +[AttributeUsage(AttributeTargets.Property)] +public class ModdedPlayerOptionAttribute( + string title, + bool allowNone = true) + : ModdedOptionAttribute(title) +{ + internal override IModdedOption? CreateOption(object? value, PropertyInfo property) + { + var option = new ModdedPlayerOption(Title, allowNone); + return option; + } + + /// + public override object GetValue() + { + if (HolderOption is not ModdedPlayerOption opt) + throw new InvalidOperationException($"Holder option for {Title} is not a ModdedPlayerOption."); + + if (ValueType == typeof(int)) + return opt.Value; + + return opt.SelectedPlayer!; + } + + /// + public override void SetValue(object value) + { + if (HolderOption is not ModdedPlayerOption opt) + return; + + var final = value switch + { + NetworkedPlayerInfo info => info ? opt.Values.IndexOf(info) : (allowNone ? -1 : 0), + _ => (int)value, + }; + opt?.SetValue(final); + } +} diff --git a/MiraAPI/GameOptions/Attributes/ModdedStringOptionAttribute.cs b/MiraAPI/GameOptions/Attributes/ModdedStringOptionAttribute.cs new file mode 100644 index 0000000..9086b1f --- /dev/null +++ b/MiraAPI/GameOptions/Attributes/ModdedStringOptionAttribute.cs @@ -0,0 +1,38 @@ +using System; +using System.Reflection; +using MiraAPI.GameOptions.OptionTypes; + +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, (string)(value ?? string.Empty), values); + return option; + } + + /// + public override object GetValue() + { + return HolderOption is ModdedStringOption opt + ? 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/ModdedToggleOptionAttribute.cs b/MiraAPI/GameOptions/Attributes/ModdedToggleOptionAttribute.cs index 825915d..042c1f3 100644 --- a/MiraAPI/GameOptions/Attributes/ModdedToggleOptionAttribute.cs +++ b/MiraAPI/GameOptions/Attributes/ModdedToggleOptionAttribute.cs @@ -1,7 +1,6 @@ using System; using System.Reflection; using MiraAPI.GameOptions.OptionTypes; -using MiraAPI.Translation; namespace MiraAPI.GameOptions.Attributes; @@ -9,12 +8,27 @@ namespace MiraAPI.GameOptions.Attributes; /// Attribute for a . /// /// The option title. +/// What the option displays when toggled on. +/// What the option displays when toggled off. [AttributeUsage(AttributeTargets.Property)] -public class ModdedToggleOptionAttribute(string title) : ModdedOptionAttribute(title) +public class ModdedToggleOptionAttribute( + string title, + string on, + string off) + : ModdedOptionAttribute(title) { + /// + /// Initializes a new instance of the class. + /// + /// The option title. + public ModdedToggleOptionAttribute(string title) + : this(title, "On", "Off") + { + } + internal override IModdedOption CreateOption(object? value, PropertyInfo property) { - var toggleOpt = new ModdedToggleOption(Title.Translate(), (bool)(value ?? false)); + var toggleOpt = new ModdedToggleOption(Title, (bool)(value ?? false), on, off); return toggleOpt; } @@ -29,7 +43,7 @@ public class ModdedToggleOptionAttribute(string title) : ModdedOptionAttribute(t public override object GetValue() { return HolderOption is ModdedToggleOption opt - ? (object)opt.Value + ? opt.Value : throw new InvalidOperationException($"Holder option for {Title} is not a ModdedToggleOption."); } } diff --git a/MiraAPI/GameOptions/Attributes/OptionList/ModdedEnumOptionListAttribute.cs b/MiraAPI/GameOptions/Attributes/OptionList/ModdedEnumOptionListAttribute.cs index 2104860..bf2fdf0 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) @@ -31,7 +34,7 @@ public class ModdedEnumOptionListAttribute(string title, Type enumType, string[] { return HolderOptionList?[idx] is ModdedEnumOption opt ? Enum.ToObject(enumType, opt.Value) - : throw new InvalidOperationException($"HolderOption for option \"{GetFormattedTitle(idx)}\" with EnumType ${enumType.FullName} is not a ModdedEnumOption"); + : throw new InvalidOperationException($"HolderOption for option \"{GetFormattedTitle(idx)}\" with EnumType {enumType.FullName} is not a ModdedEnumOption"); } } @@ -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) { @@ -63,6 +68,6 @@ public class ModdedEnumOptionListAttribute(string title, string[]? values = n { return HolderOptionList?[idx] is ModdedEnumOption opt ? opt.Value - : throw new InvalidOperationException($"HolderOption for option \"{GetFormattedTitle(idx)}\" with EnumType ${typeof(T).FullName} is not a ModdedEnumOption"); + : throw new InvalidOperationException($"HolderOption for option \"{GetFormattedTitle(idx)}\" with EnumType {typeof(T).FullName} is not a ModdedEnumOption<{typeof(T).FullName}>"); } } diff --git a/MiraAPI/GameOptions/Attributes/OptionList/ModdedNumberOptionListAttribute.cs b/MiraAPI/GameOptions/Attributes/OptionList/ModdedNumberOptionListAttribute.cs index 80fdeda..c393db3 100644 --- a/MiraAPI/GameOptions/Attributes/OptionList/ModdedNumberOptionListAttribute.cs +++ b/MiraAPI/GameOptions/Attributes/OptionList/ModdedNumberOptionListAttribute.cs @@ -7,47 +7,102 @@ 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. +/// Determines what is shown for zero. +/// Determines what is shown for negative 1. +/// The suffix type. +/// Optional format string for the option screen. +/// Whether increments can be split in half (using Shift). [AttributeUsage(AttributeTargets.Property)] public class ModdedNumberOptionListAttribute( string title, float min, float max, - float increment = 1, + float increment, + string zeroBehavior, + string negativeBehavior, MiraNumberSuffixes suffixType = MiraNumberSuffixes.None, string? formatString = null, - bool zeroInfinity = false) + bool halfIncrements = false) : ModdedOptionListAttribute(title) { + /// + /// Initializes a new instance of the class. + /// + /// The title of the option. + /// The minimum value. + /// The maximum value. + /// The increment. + /// The suffix type. + /// Whether zero is infinity or not. + /// Optional format string for the option screen. + public ModdedNumberOptionListAttribute( + string title, + float min, + float max, + float increment = 1, + MiraNumberSuffixes suffixType = MiraNumberSuffixes.None, + string? formatString = null, + bool zeroInfinity = false) + : this(title, min, max, increment, zeroInfinity ? "∞" : "#", "#", suffixType, formatString, increment == 1f) + { + } + internal override IModdedOptionList CreateOptionList(IList value, PropertyInfo property) { return new ModdedOptionList(value.Count, idx => { + var final = value[idx] switch + { + null => min + increment, + int integer => integer, + _ => (float)value[idx]!, + }; + return new( GetFormattedTitle(idx), - (float)(value[idx] ?? min + increment), + final, min, max, increment, + zeroBehavior, + negativeBehavior, suffixType, formatString, - zeroInfinity); + halfIncrements); }); } /// public override void SetValue(int idx, object value) { - var opt = HolderOptionList?[idx] as ModdedNumberOption; - opt?.SetValue((float)value); + if (HolderOptionList?[idx] is not ModdedNumberOption opt) + return; + + var final = value switch + { + int integer => integer, + _ => (float)value, + }; + opt.SetValue(final); } /// public override object GetValue(int idx) { - return HolderOptionList?[idx] is ModdedNumberOption opt - ? (object)opt.Value - : throw new InvalidOperationException($"HolderOption for option \"{GetFormattedTitle(idx)}\" is not a ModdedNumberOption"); + if (HolderOptionList?[idx] is not ModdedNumberOption opt) + throw new InvalidOperationException($"HolderOption for option \"{GetFormattedTitle(idx)}\" is not a ModdedNumberOption"); + + var value = opt.Value; + + if (ValueType == typeof(int)) + return (int)Math.Round(value); + + return value; } } diff --git a/MiraAPI/GameOptions/Attributes/OptionList/ModdedOptionListAttribute.cs b/MiraAPI/GameOptions/Attributes/OptionList/ModdedOptionListAttribute.cs index d0ee68e..d7ef6e6 100644 --- a/MiraAPI/GameOptions/Attributes/OptionList/ModdedOptionListAttribute.cs +++ b/MiraAPI/GameOptions/Attributes/OptionList/ModdedOptionListAttribute.cs @@ -3,6 +3,7 @@ using System.Collections; using System.Diagnostics.CodeAnalysis; using System.Reflection; using System.Text.RegularExpressions; +using MiraAPI.Translation; namespace MiraAPI.GameOptions.Attributes; @@ -14,8 +15,8 @@ namespace MiraAPI.GameOptions.Attributes; public abstract class ModdedOptionListAttribute(string title) : PropertyOptionAttribute { internal IModdedOptionList? HolderOptionList { get; set; } - - internal object? Value { get; set; } + internal IList? Value { get; set; } + internal Type ValueType { get; set; } /// /// Gets the title of the options. @@ -31,16 +32,16 @@ public abstract class ModdedOptionListAttribute(string title) : PropertyOptionAt /// The new values as an object. public override void SetValue(object value) { - Value = value; - var list = (IList)value; - if (list.Count != HolderOptionList!.Count) + Value = (IList)value; + + if (Value.Count != HolderOptionList!.Count) { throw new InvalidOperationException($"Value set to option list cannot change the list's length."); } - for (int i = 0; i < list.Count; i++) + for (int i = 0; i < Value.Count; i++) { - SetValue(i, list[i]!); + SetValue(i, Value[i]!); } } @@ -77,18 +78,19 @@ public abstract class ModdedOptionListAttribute(string title) : PropertyOptionAt [SuppressMessage("Globalization", "CA1305:Specify IFormatProvider", Justification = "I don't even know if this is worth it.")] protected string GetFormattedTitle(int index) { - Match match = Regex.Match(Title, @"\{(\d+)(?::[^}]+)?\}", default, Regex.InfiniteMatchTimeout); + var match = Regex.Match(Title.Translate(), @"\{(\d+)(?::[^}]+)?\}", default, Regex.InfiniteMatchTimeout); - if (match.Success) + if (!match.Success) { - if (match.Groups.Count == 1 && match.Groups[0].Value == "0") - { - return string.Format(Title, index); - } - - Error("ModdedOptionList Title cannot contain more than the first parameter for formatting."); + return Title + index; } + if (match.Value == "{0}" && !match.NextMatch().Success) + { + return string.Format(Title, index); + } + + Error($"ModdedOptionList Title of {Title} cannot contain more than the first parameter for formatting."); return Title + index; } } diff --git a/MiraAPI/GameOptions/Attributes/OptionList/ModdedPlayerOptionListAttribute.cs b/MiraAPI/GameOptions/Attributes/OptionList/ModdedPlayerOptionListAttribute.cs new file mode 100644 index 0000000..533492b --- /dev/null +++ b/MiraAPI/GameOptions/Attributes/OptionList/ModdedPlayerOptionListAttribute.cs @@ -0,0 +1,52 @@ +using System; +using System.Collections; +using System.Reflection; +using MiraAPI.GameOptions.OptionTypes; + +namespace MiraAPI.GameOptions.Attributes; + +/// +/// Attribute for creating a list of player options. +/// +/// The option title. +/// Whether the option includes a none option. If None is selected, value will return -1. +[AttributeUsage(AttributeTargets.Property)] +public class ModdedPlayerOptionListAttribute( + string title, + bool allowNone = true) + : ModdedOptionListAttribute(title) +{ + internal override IModdedOptionList CreateOptionList(IList value, PropertyInfo property) + { + return new ModdedOptionList(value.Count, idx => + { + return new(GetFormattedTitle(idx), allowNone); + }); + } + + /// + public override void SetValue(int idx, object value) + { + if (HolderOptionList?[idx] is not ModdedPlayerOption opt) + return; + + var final = value switch + { + NetworkedPlayerInfo info => info ? opt.Values.IndexOf(info) : (allowNone ? -1 : 0), + _ => (int)value, + }; + opt?.SetValue(final); + } + + /// + public override object GetValue(int idx) + { + if (HolderOptionList?[idx] is not ModdedPlayerOption opt) + throw new InvalidOperationException($"HolderOption for option \"{GetFormattedTitle(idx)}\" is not a ModdedPlayerOption"); + + if (ValueType == typeof(int)) + return opt.Value; + + return opt.SelectedPlayer!; + } +} diff --git a/MiraAPI/GameOptions/Attributes/OptionList/ModdedStringOptionListAttribute.cs b/MiraAPI/GameOptions/Attributes/OptionList/ModdedStringOptionListAttribute.cs new file mode 100644 index 0000000..78c4b66 --- /dev/null +++ b/MiraAPI/GameOptions/Attributes/OptionList/ModdedStringOptionListAttribute.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 + ? opt.Value + : throw new InvalidOperationException($"HolderOption for option \"{GetFormattedTitle(idx)}\" is not a ModdedStringOption"); + } +} diff --git a/MiraAPI/GameOptions/Attributes/OptionList/ModdedToggleOptionListAttribute.cs b/MiraAPI/GameOptions/Attributes/OptionList/ModdedToggleOptionListAttribute.cs index 0bcb0a6..9dab9a4 100644 --- a/MiraAPI/GameOptions/Attributes/OptionList/ModdedToggleOptionListAttribute.cs +++ b/MiraAPI/GameOptions/Attributes/OptionList/ModdedToggleOptionListAttribute.cs @@ -9,13 +9,28 @@ namespace MiraAPI.GameOptions.Attributes; /// Attribute for a list of toggle options. /// /// The option's title. +/// What the option displays when toggled on. +/// What the option displays when toggled off. [AttributeUsage(AttributeTargets.Property)] -public class ModdedToggleOptionListAttribute(string title) : ModdedOptionListAttribute(title) +public class ModdedToggleOptionListAttribute( + string title, + string on, + string off) + : ModdedOptionListAttribute(title) { + /// + /// Initializes a new instance of the class. + /// + /// The option's title. + public ModdedToggleOptionListAttribute(string title) + : this(title, "On", "Off") + { + } + internal override IModdedOptionList CreateOptionList(IList value, PropertyInfo property) { var optList = new ModdedOptionList( - value.Count, idx => new(GetFormattedTitle(idx), (bool)(value[idx] ?? false))); + value.Count, idx => new(GetFormattedTitle(idx), (bool)(value[idx] ?? false), on, off)); return optList; } @@ -30,7 +45,7 @@ public class ModdedToggleOptionListAttribute(string title) : ModdedOptionListAtt public override object GetValue(int idx) { return HolderOptionList?[idx] is ModdedToggleOption opt - ? (object)opt.Value + ? opt.Value : throw new InvalidOperationException($"Holder option for {GetFormattedTitle(idx)} is not a ModdedToggleOption."); } } diff --git a/MiraAPI/GameOptions/CollectionBuilder.cs b/MiraAPI/GameOptions/CollectionBuilder.cs new file mode 100644 index 0000000..99d42d9 --- /dev/null +++ b/MiraAPI/GameOptions/CollectionBuilder.cs @@ -0,0 +1,8 @@ +namespace System.Runtime.CompilerServices; + +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Interface, Inherited = false)] +internal sealed class CollectionBuilderAttribute(Type builderType, string methodName) : Attribute +{ + public Type BuilderType { get; } = builderType; + public string MethodName { get; } = methodName; +} 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/ModdedList.cs b/MiraAPI/GameOptions/ModdedList.cs new file mode 100644 index 0000000..65f60ef --- /dev/null +++ b/MiraAPI/GameOptions/ModdedList.cs @@ -0,0 +1,294 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Runtime.CompilerServices; + +namespace MiraAPI.GameOptions; + +/// +/// A fixed-size array created specifically for modded options that automatically synchronizes with option attributes. +/// +/// The type of the values that the array holds. +[CollectionBuilder(typeof(ModdedArrayBuilder), nameof(ModdedArrayBuilder.Create))] +public sealed class ModdedArray : IList, IReadOnlyList, IList +{ + private readonly T[] items; + private int version; + + /// + public int Count => items.Length; + + /// + public bool IsReadOnly => false; + + /// + bool IList.IsFixedSize => true; + + /// + bool ICollection.IsSynchronized => false; + + /// + object ICollection.SyncRoot => items.SyncRoot; + + /// + /// Initializes a new instance of the class with the provided capacity. + /// + /// The capacity of the list. + public ModdedArray(int capacity) + { + items = new T[capacity]; + } + + /// + /// Initializes a new instance of the class with initial values. + /// + /// The initial collection of values to initialise with. + public ModdedArray(IEnumerable collection) + { + items = [.. collection]; + } + + /// + /// Initializes a new instance of the class with initial values. + /// + /// The initial span of values to initialise with. + public ModdedArray(Span span) + { + items = [.. span]; + } + + /// + /// Initializes a new instance of the class with initial values. + /// + /// The initial span of values to initialise with. + public ModdedArray(ReadOnlySpan span) + { + items = [.. span]; + } + + /// + /// Gets or sets the element at the specified index, syncing with the attribute if registered. + /// + /// The index of the value. + public T this[int index] + { + get => ModdedOptionsManager.ListInstanceCache.TryGetValue(this, out var attribute) + ? (T)attribute.GetValue(index) + : items[index]; + set + { + if (ModdedOptionsManager.ListInstanceCache.TryGetValue(this, out var attribute)) + attribute.SetValue(index, value!); + else + items[index] = value; + + version++; + } + } + + /// + object? IList.this[int index] + { + get => this[index]; + set => this[index] = (T)value!; + } + + /// + public bool Contains(T item) + { + return Array.IndexOf(items, item) >= 0; + } + + /// + public void CopyTo(T[] array, int arrayIndex) + { + items.CopyTo(array, arrayIndex); + } + + /// + public int IndexOf(T item) + { + return Array.IndexOf(items, item); + } + + /// + public Enumerator GetEnumerator() + { + return new(this); + } + + /// + bool IList.Contains(object? value) + { + return ((IList)items).Contains(value); + } + + /// + int IList.IndexOf(object? value) + { + return ((IList)items).IndexOf(value); + } + + /// + void ICollection.CopyTo(Array array, int index) + { + items.CopyTo(array, index); + } + + /// + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + + /// + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + + /// + int IList.Add(object? value) + { + throw new NotSupportedException("ModdedArray is of a fixed size."); + } + + /// + void IList.Clear() + { + throw new NotSupportedException("ModdedArray is of a fixed size."); + } + + /// + void IList.Insert(int index, object? value) + { + throw new NotSupportedException("ModdedArray is of a fixed size."); + } + + /// + void IList.Remove(object? value) + { + throw new NotSupportedException("ModdedArray is of a fixed size."); + } + + /// + void IList.RemoveAt(int index) + { + throw new NotSupportedException("ModdedArray is of a fixed size."); + } + + /// + public void Insert(int index, T item) + { + throw new NotSupportedException("ModdedArray is of a fixed size."); + } + + /// + public void RemoveAt(int index) + { + throw new NotSupportedException("ModdedArray is of a fixed size."); + } + + /// + public void Add(T item) + { + throw new NotSupportedException("ModdedArray is of a fixed size."); + } + + /// + public void Clear() + { + throw new NotSupportedException("ModdedArray is of a fixed size."); + } + + /// + public bool Remove(T item) + { + throw new NotSupportedException("ModdedArray is of a fixed size."); + } + + /// + /// Enumerates the elements of a . + /// + public struct Enumerator : IEnumerator + { + private readonly ModdedArray array; + private readonly int version; + + private int index; + private T? current; + + internal Enumerator(ModdedArray moddedArray) + { + array = moddedArray; + version = moddedArray.version; + index = 0; + current = default; + } + + /// + public readonly T Current => current!; + + /// + readonly object? IEnumerator.Current + { + get + { + if (index == 0 || index == array.Count + 1) + throw new InvalidOperationException("Enumeration has not started or has already finished."); + + return Current; + } + } + + /// + public readonly void Dispose() + { + } + + /// + public bool MoveNext() + { + if (version != array.version) + throw new InvalidOperationException("Collection was modified; enumeration operation may not execute."); + + if ((uint)index < (uint)array.Count) + { + current = array[index]; + index++; + return true; + } + + index = array.Count + 1; + current = default; + return false; + } + + /// + public void Reset() + { + if (version != array.version) + throw new InvalidOperationException("Collection was modified; enumeration operation may not execute."); + + index = 0; + current = default; + } + } +} + +/// +/// Provides a builder for to support C# 12 collection expressions. +/// +public static class ModdedArrayBuilder +{ + /// + /// Creates a new from a span of values. + /// + /// The the type of the values. + /// The values to initialise with. + /// The instance initialised with the provided values. + public static ModdedArray Create(ReadOnlySpan values) + { + return new(values); + } +} diff --git a/MiraAPI/GameOptions/ModdedOptionsManager.cs b/MiraAPI/GameOptions/ModdedOptionsManager.cs index b893040..3e11b3d 100644 --- a/MiraAPI/GameOptions/ModdedOptionsManager.cs +++ b/MiraAPI/GameOptions/ModdedOptionsManager.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Reflection; +using System.Runtime.CompilerServices; using BepInEx.Configuration; using HarmonyLib; using MiraAPI.GameModes; @@ -24,9 +25,15 @@ namespace MiraAPI.GameOptions; /// public static class ModdedOptionsManager { - private static readonly Dictionary OptionAttributes = []; + private static readonly Dictionary GetterLookups = []; + private static readonly Dictionary SetterLookups = []; private static readonly Dictionary TypeToGroup = []; + private static readonly HashSet OptionAttributes = []; + private static readonly HarmonyMethod CachedGetterPatch = new(typeof(ModdedOptionsManager), nameof(PropertyGetterPatch)); + private static readonly HarmonyMethod CachedSetterPatch = new(typeof(ModdedOptionsManager), nameof(PropertySetterPatch)); + + internal static readonly ConditionalWeakTable ListInstanceCache = []; internal static readonly Dictionary CreatedPlayerOptions = []; internal static readonly Dictionary CreatedStringOptions = []; internal static readonly Dictionary> GameModeOptionGroups = []; @@ -61,14 +68,14 @@ public static class ModdedOptionsManager internal static bool RegisterGroup(Type type) { - if (Activator.CreateInstance(type) is not AbstractOptionGroup group) + if (TypeToGroup.ContainsKey(type)) { + Error($"Group {type.Name} already exists."); return false; } - if (TypeToGroup.ContainsKey(type)) + if (Activator.CreateInstance(type) is not AbstractOptionGroup group) { - Logger.Error($"Group {type.Name} already exists."); return false; } @@ -78,17 +85,17 @@ public static class ModdedOptionsManager internal static bool RegisterGroup(Type type, MiraPluginInfo pluginInfo) { - if (Activator.CreateInstance(type) is not AbstractOptionGroup group) - { - return false; - } - if (TypeToGroup.ContainsKey(type)) { Error($"Group {type.Name} already exists."); return false; } + if (Activator.CreateInstance(type) is not AbstractOptionGroup group) + { + return false; + } + if (typeof(AbstractGameMode).IsAssignableFrom(group.OptionableType)) { if (GameModeOptionGroups.TryGetValue(group.OptionableType, out var oldList)) @@ -116,7 +123,7 @@ public static class ModdedOptionsManager Groups.Add(group); TypeToGroup.Add(type, group); typeof(OptionGroupSingleton<>).MakeGenericType(type) - .GetField("_instance", BindingFlags.Static | BindingFlags.NonPublic)! // Message suppression points to here + .GetField("_instance", BindingFlags.Static | BindingFlags.NonPublic)! .SetValue(null, group); } @@ -143,7 +150,7 @@ public static class ModdedOptionsManager PropertyInfo property, MiraPluginInfo pluginInfo) { - if (OptionAttributes.ContainsKey(property)) + if (!OptionAttributes.Add(property)) { Error($"Property {property.Name} already has an attribute registered."); return; @@ -163,22 +170,40 @@ public static class ModdedOptionsManager return; } - var setterOriginal = property.GetSetMethod(); - var setterPatch = typeof(ModdedOptionsManager).GetMethod(nameof(PropertySetterPatch)); - PluginSingleton.Instance.Harmony.Patch(setterOriginal, postfix: new HarmonyMethod(setterPatch)); + var getterOriginal = property.GetGetMethod(true)!; + PluginSingleton.Instance.Harmony.Patch(getterOriginal, prefix: CachedGetterPatch); + GetterLookups[getterOriginal] = attribute; - var getterOriginal = property.GetGetMethod(); - var getterPatch = typeof(ModdedOptionsManager).GetMethod(nameof(PropertyGetterPatch)); - PluginSingleton.Instance.Harmony.Patch(getterOriginal, prefix: new HarmonyMethod(getterPatch)); + var setterOriginal = property.GetSetMethod(true); - OptionAttributes.Add(property, attribute); - attribute.HolderOption = option; - - var visibilityAttr = property.GetCustomAttribute(); - var visibilityFunc = visibilityAttr?.GetVisibility(group, property); - if (visibilityFunc != null) + if (setterOriginal != null) { - option.Visible = visibilityFunc; + PluginSingleton.Instance.Harmony.Patch(setterOriginal, postfix: CachedSetterPatch); + SetterLookups[setterOriginal] = attribute; + } + + attribute.HolderOption = option; + attribute.ValueType = property.PropertyType; + + var customAttributes = property.GetCustomAttributes(false); + + foreach (var attr in customAttributes) + { + switch (attr) + { + case ModdedOptionVisibilityAttribute visibilityAttr: + option.Visible = visibilityAttr.GetVisibility(group, property) ?? DelegateUtilities.AlwaysTrue; + break; + case ModdedOptionGetterAttribute getterAttr: + option.Getter = getterAttr.GetGetter(group, property, option); + break; + case ModdedOptionSetterAttribute setterAttr: + option.Setter = setterAttr.GetSetter(group, property, option); + break; + case ModdedOptionChangedAttribute changedAttr: + option.ChangedEvent = changedAttr.GetChanged(group, property, option); + break; + } } RegisterOption(option, group, property.Name, pluginInfo); @@ -198,9 +223,9 @@ public static class ModdedOptionsManager return; } - Func configName = optionList.ConfigName ?? (i => property.Name + (i + 1)); + var configName = optionList.ConfigName ?? (i => property.Name + (i + 1)); - for (int i = 0; i < optionList.Count; i++) + for (var i = 0; i < optionList.Count; i++) { RegisterOption(optionList[i], group, configName(i), pluginInfo); } @@ -212,7 +237,7 @@ public static class ModdedOptionsManager PropertyInfo property, MiraPluginInfo pluginInfo) { - if (OptionAttributes.ContainsKey(property)) + if (!OptionAttributes.Add(property)) { Error($"Property {property.Name} already has an attribute registered."); return; @@ -247,37 +272,71 @@ public static class ModdedOptionsManager return; } - var setterOriginal = property.GetSetMethod(); - var setterPatch = typeof(ModdedOptionsManager).GetMethod(nameof(PropertySetterPatch)); - PluginSingleton.Instance.Harmony.Patch(setterOriginal, postfix: new HarmonyMethod(setterPatch)); + var listType = propertyList.GetType(); + var valueType = listType.GetGenericArguments()[0]; - var getterOriginal = property.GetGetMethod(); - var getterPatch = typeof(ModdedOptionsManager).GetMethod(nameof(PropertyGetterPatch)); - PluginSingleton.Instance.Harmony.Patch(getterOriginal, prefix: new HarmonyMethod(getterPatch)); + if (!listType.IsGenericType || listType.GetGenericTypeDefinition() != typeof(ModdedArray<>)) + { + Error($"Please use ModdedList<{valueType.Name}> for modded option lists."); + return; + } - var listIndex = propertyList.GetType().GetProperty("Item")!; + var getterOriginal = property.GetGetMethod(true)!; + PluginSingleton.Instance.Harmony.Patch(getterOriginal, prefix: CachedGetterPatch); + GetterLookups[getterOriginal] = attribute; - var listSetterOriginal = listIndex.GetSetMethod(); - var listSetterPatch = typeof(ModdedOptionsManager).GetMethod(nameof(PropertyListSetterPatch)); - PluginSingleton.Instance.Harmony.Patch(listSetterOriginal, postfix: new HarmonyMethod(listSetterPatch)); + var setterOriginal = property.GetSetMethod(true); - var listGetterOriginal = listIndex.GetSetMethod(); - var listGetterPatch = typeof(ModdedOptionsManager).GetMethod(nameof(PropertyListGetterPatch)); - PluginSingleton.Instance.Harmony.Patch(listGetterOriginal, prefix: new HarmonyMethod(listGetterPatch)); + if (setterOriginal != null) + { + PluginSingleton.Instance.Harmony.Patch(setterOriginal, postfix: CachedSetterPatch); + SetterLookups[setterOriginal] = attribute; + } - OptionAttributes.Add(property, attribute); attribute.HolderOptionList = optionList; - attribute.Value = propertyVal; + attribute.Value = propertyList; + attribute.ValueType = valueType; - var visibilityAttr = property.GetCustomAttribute(); - var visibilityFunc = visibilityAttr?.GetListVisibility(group, property); - for (int i = 0; i < optionList.Count; i++) + ListInstanceCache.Add(propertyVal, attribute); + + var customAttributes = property.GetCustomAttributes(false); + + Func? visibilityFactory = null, getterFactory = null, setterFactory = null, changedFactory = null; + + foreach (var attr in customAttributes) + { + switch (attr) + { + case ModdedOptionVisibilityAttribute visibilityAttr: + visibilityFactory = visibilityAttr.GetListVisibility(group, property); + break; + case ModdedOptionGetterAttribute getterAttr: + getterFactory = getterAttr.GetListGetter(group, property, valueType); + break; + case ModdedOptionSetterAttribute setterAttr: + setterFactory = setterAttr.GetListSetter(group, property, valueType); + break; + case ModdedOptionChangedAttribute changedAttr: + changedFactory = changedAttr.GetListChanged(group, property, valueType); + break; + } + } + + for (var i = 0; i < optionList.Count; i++) { var option = optionList[i]; - if (visibilityFunc != null) - { - option.Visible = () => visibilityFunc(i); - } + + if (visibilityFactory != null) + option.Visible = (Func)(visibilityFactory(i) ?? DelegateUtilities.AlwaysTrue); + + 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,11 +409,16 @@ 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; + if (!SetterLookups.TryGetValue(__originalMethod, out var attribute)) + return; + attribute.SetValue(value); + + if (attribute is ModdedOptionListAttribute listAttribute && value != null) + ListInstanceCache.AddOrUpdate(value, listAttribute); } /// @@ -364,43 +428,13 @@ 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; + if (!GetterLookups.TryGetValue(__originalMethod, out var attribute)) + return true; + __result = attribute.GetValue(); return false; } - - /// - /// Patches the setter of a list property to update the value of the option. - /// - /// The list's instance. - /// The index to find in the list. - /// The new object value. - [SuppressMessage("ReSharper", "InconsistentNaming", Justification = "Harmony naming convention")] - [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); - } - - /// - /// Patches the getter of a list property to return the value of the option. - /// - /// The list's instance. - /// The index to find in the list. - /// The result of the property getter. - /// False so the original getter gets skipped. - [SuppressMessage("ReSharper", "InconsistentNaming", Justification = "Harmony naming convention")] - [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); - return false; - } } diff --git a/MiraAPI/GameOptions/OptionTypes/ModdedEnumOption.cs b/MiraAPI/GameOptions/OptionTypes/ModdedEnumOption.cs index 69815b9..7931d02 100644 --- a/MiraAPI/GameOptions/OptionTypes/ModdedEnumOption.cs +++ b/MiraAPI/GameOptions/OptionTypes/ModdedEnumOption.cs @@ -50,7 +50,11 @@ public class ModdedEnumOption : ModdedOption $"{ParentMod!.OptionsTitleText}.EnumOption.{TranslationController.Instance.GetString(StringName)}"; stringOption.SetUpFromData(Data, 20); - stringOption.OnValueChanged = (Il2CppSystem.Action)ValueChanged; + stringOption.OnValueChanged = (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; @@ -113,7 +117,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 +134,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; @@ -149,7 +153,11 @@ public class ModdedEnumOption : ModdedOption $"{ParentMod!.OptionsTitleText}.EnumOption.{TranslationController.Instance.GetString(StringName)}"; stringOption.SetUpFromData(Data, 20); - stringOption.OnValueChanged = (Il2CppSystem.Action)ValueChanged; + stringOption.OnValueChanged = (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; @@ -176,13 +184,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/ModdedNumberOption.cs b/MiraAPI/GameOptions/OptionTypes/ModdedNumberOption.cs index e9097ee..f27ed13 100644 --- a/MiraAPI/GameOptions/OptionTypes/ModdedNumberOption.cs +++ b/MiraAPI/GameOptions/OptionTypes/ModdedNumberOption.cs @@ -61,10 +61,10 @@ public class ModdedNumberOption : ModdedOption /// The maximum value. /// The increment. /// The suffix type. - /// Optional format string for the option screen. - /// Determines what is shown for zero. Options: ∞, #, or a word value (such as Off). - /// Determines what is shown for negative 1. Options: ∞, #, or a word value (such as Off). + /// Determines what is shown for zero. + /// Determines what is shown for negative 1. /// Whether increments can be split in half. + /// Optional format string for the option screen. /// Whether to include this option in the preset or not. public ModdedNumberOption( string title, @@ -85,14 +85,12 @@ public class ModdedNumberOption : ModdedOption Increment = increment; SuffixType = suffixType; ZeroWordValue = zeroBehavior; - NegativeWordValue = negativeBehavior; ShiftIncrement = halfIncrements; + NegativeWordValue = negativeBehavior; Value = Mathf.Clamp(defaultValue, min, max); - Data = ScriptableObject.CreateInstance(); - - var data = (FloatGameSetting)Data; + var data = ScriptableObject.CreateInstance(); data.Type = global::OptionTypes.Float; data.Title = StringName; data.Value = Value; @@ -108,6 +106,8 @@ public class ModdedNumberOption : ModdedOption data.ZeroIsInfinity = ZeroInfinity; data.SuffixType = (NumberSuffixes)SuffixType; data.OptionName = FloatOptionNames.Invalid; + + Data = data; } /// @@ -119,8 +119,8 @@ public class ModdedNumberOption : ModdedOption /// The maximum value. /// The increment. /// The suffix type. - /// Optional format string for the option screen. /// Whether zero is infinity or not. + /// Optional format string for the option screen. /// Whether to include this option in the preset or not. public ModdedNumberOption( string title, @@ -132,36 +132,8 @@ public class ModdedNumberOption : ModdedOption string? formatString = null, bool zeroInfinity = false, bool includeInPreset = true) - : base(title, defaultValue, includeInPreset) + : this(title, defaultValue, min, max, increment, zeroInfinity ? "∞" : "#", "#", suffixType, formatString, increment == 1f, includeInPreset) { - Min = min; - Max = max; - Increment = increment; - SuffixType = suffixType; - ZeroWordValue = zeroInfinity ? "∞" : "#"; - NegativeWordValue = "#"; - ShiftIncrement = Mathf.Approximately(increment, 1f); - - Value = Mathf.Clamp(defaultValue, min, max); - - Data = ScriptableObject.CreateInstance(); - - var data = (FloatGameSetting)Data; - data.Type = global::OptionTypes.Float; - data.Title = StringName; - data.Value = Value; - data.Increment = Increment; - data.ValidRange = new FloatRange(Min, Max); - data.FormatString = formatString ?? - (defaultValue.IsInteger() && - Increment.IsInteger() && - Value.IsInteger() && - Min.IsInteger() && - Max.IsInteger() ? "0" : "0.0"); - - data.ZeroIsInfinity = ZeroInfinity; - data.SuffixType = (NumberSuffixes)SuffixType; - data.OptionName = FloatOptionNames.Invalid; } /// diff --git a/MiraAPI/GameOptions/OptionTypes/ModdedOption.cs b/MiraAPI/GameOptions/OptionTypes/ModdedOption.cs index 24747ad..e2486ae 100644 --- a/MiraAPI/GameOptions/OptionTypes/ModdedOption.cs +++ b/MiraAPI/GameOptions/OptionTypes/ModdedOption.cs @@ -1,8 +1,10 @@ using System; +using System.Collections.Generic; using BepInEx.Configuration; using MiraAPI.Networking; using MiraAPI.PluginLoading; using MiraAPI.Translation; +using MiraAPI.Utilities; using Reactor.Networking.Rpc; using UnityEngine; @@ -43,16 +45,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 +85,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. /// @@ -80,7 +125,7 @@ public abstract class ModdedOption : IModdedOption DefaultValue = defaultValue; Value = defaultValue; StringName = MiraLocaleManager.GetOrCreateLocaleString(Title); - Visible = () => true; + Visible = DelegateUtilities.AlwaysTrue; IncludeInPreset = includeInPreset; } @@ -96,25 +141,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 +194,12 @@ public abstract class ModdedOption : IModdedOption } } + /// + public Type GetValueType() + { + return typeof(T); + } + /// public abstract float GetFloatData(); @@ -188,10 +239,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..6e9e9ad 100644 --- a/MiraAPI/GameOptions/OptionTypes/ModdedOptionList.cs +++ b/MiraAPI/GameOptions/OptionTypes/ModdedOptionList.cs @@ -44,22 +44,6 @@ public class ModdedOptionList : IModdedOptionList Options = [.. options]; } - /// - public IEnumerator GetEnumerator() - { - return Options.GetEnumerator(); - } - - IEnumerator IEnumerable.GetEnumerator() - { - return Options.GetEnumerator(); - } - - IEnumerator IEnumerable.GetEnumerator() - { - return ((IEnumerable)Options).GetEnumerator(); - } - /// /// Indexes the option of type . /// @@ -67,5 +51,24 @@ public class ModdedOptionList : IModdedOptionList /// option of type . public T this[int idx] => Options[idx]; + /// + public IEnumerator GetEnumerator() + { + return Options.GetEnumerator(); + } + + /// + IEnumerator IEnumerable.GetEnumerator() + { + return Options.GetEnumerator(); + } + + /// + IEnumerator IEnumerable.GetEnumerator() + { + return ((IEnumerable)Options).GetEnumerator(); + } + + /// IModdedOption IReadOnlyList.this[int index] => this[index]; } diff --git a/MiraAPI/GameOptions/OptionTypes/ModdedPlayerOption.cs b/MiraAPI/GameOptions/OptionTypes/ModdedPlayerOption.cs index 40cf0f8..513aad0 100644 --- a/MiraAPI/GameOptions/OptionTypes/ModdedPlayerOption.cs +++ b/MiraAPI/GameOptions/OptionTypes/ModdedPlayerOption.cs @@ -29,6 +29,11 @@ public class ModdedPlayerOption : ModdedOption /// public bool AllowNone { get; set; } + /// + /// Gets the player selected by this option. May be null if allowed to have None as an option. + /// + public NetworkedPlayerInfo? SelectedPlayer => Value == -1 ? null : Values[Value]; + /// /// Initializes a new instance of the class. /// @@ -37,15 +42,16 @@ public class ModdedPlayerOption : ModdedOption public ModdedPlayerOption(string title, bool allowNone = true) : base(title, allowNone ? -1 : 0, false) { - Data = ScriptableObject.CreateInstance(); - var data = (PlayerSelectionGameSetting)Data; + Values = []; + AllowNone = allowNone; + Value = DefaultValue; + + var data = ScriptableObject.CreateInstance(); data.Title = StringName; data.Type = global::OptionTypes.Player; - Values = []; - AllowNone = allowNone; - Value = DefaultValue; + Data = data; } /// @@ -63,7 +69,7 @@ public class ModdedPlayerOption : ModdedOption Values = GetFilteredPlayers(); playerOption.SetUpFromData(Data, 20); - playerOption.OnValueChanged = (Il2CppSystem.Action)ValueChanged; + playerOption.OnValueChanged = (Action)ValueChanged; playerOption.Title = StringName; playerOption.TitleText.text = Title.Translate(); @@ -86,7 +92,14 @@ public class ModdedPlayerOption : ModdedOption /// A list of filtered players. public List GetFilteredPlayers() { - return [.. GameData.Instance.AllPlayers.ToArray().Where(x => PlayerFilter?.Invoke(x) ?? true)]; + var players = GameData.Instance.AllPlayers.ToArray(); + + IEnumerable filtered = players; + + if (PlayerFilter != null) + filtered = players.Where(PlayerFilter); + + return [.. filtered.OrderBy(x => x.PlayerId)]; } /// diff --git a/MiraAPI/GameOptions/OptionTypes/ModdedStringOption.cs b/MiraAPI/GameOptions/OptionTypes/ModdedStringOption.cs index 8381177..b1c9cc1 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; + StringNameValues = [.. values.Select(MiraLocaleManager.GetOrCreateLocaleString)]; + + var data = ScriptableObject.CreateInstance(); + data.Title = StringName; + data.Type = global::OptionTypes.String; + data.Values = StringNameValues; + data.Index = GetIndex(Value); + + Data = data; + } + /// /// 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"); } /// @@ -126,7 +124,11 @@ public class ModdedStringOption : ModdedOption $"{ParentMod!.OptionsTitleText}.StringOption.{TranslationController.Instance.GetString(StringName)}"; stringOption.SetUpFromData(Data, 20); - stringOption.OnValueChanged = (Il2CppSystem.Action)ValueChanged; + stringOption.OnValueChanged = (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; diff --git a/MiraAPI/GameOptions/OptionTypes/ModdedToggleOption.cs b/MiraAPI/GameOptions/OptionTypes/ModdedToggleOption.cs index 6fa8be3..a0759c6 100644 --- a/MiraAPI/GameOptions/OptionTypes/ModdedToggleOption.cs +++ b/MiraAPI/GameOptions/OptionTypes/ModdedToggleOption.cs @@ -11,6 +11,37 @@ namespace MiraAPI.GameOptions.OptionTypes; /// public class ModdedToggleOption : ModdedOption { + /// + /// Gets or sets the display value when the option is toggled on. + /// + public string OnValue { get; set; } + + /// + /// Gets or sets the display value when the option is toggled off. + /// + public string OffValue { get; set; } + + /// + /// Initializes a new instance of the class. + /// + /// The option title. + /// The default value. + /// What the option displays when toggled on. + /// What the option displays when toggled off. + /// Whether to include this option in the preset or not. + public ModdedToggleOption(string title, bool defaultValue, string on, string off, bool includeInPreset = true) + : base(title, defaultValue, includeInPreset) + { + OnValue = on; + OffValue = off; + + var data = ScriptableObject.CreateInstance(); + data.Title = StringName; + data.Type = global::OptionTypes.Checkbox; + + Data = data; + } + /// /// Initializes a new instance of the class. /// @@ -18,13 +49,8 @@ public class ModdedToggleOption : ModdedOption /// The default value. /// Whether to include this option in the preset or not. public ModdedToggleOption(string title, bool defaultValue, bool includeInPreset = true) - : base(title, defaultValue, includeInPreset) + : this(title, defaultValue, "On", "Off", includeInPreset) { - Data = ScriptableObject.CreateInstance(); - - var data = (CheckboxGameSetting)Data; - data.Title = StringName; - data.Type = global::OptionTypes.Checkbox; } /// @@ -39,7 +65,7 @@ public class ModdedToggleOption : ModdedOption toggleOption.Title = StringName; toggleOption.TitleText.text = Title.Translate(); toggleOption.CheckMark.enabled = Value; - toggleOption.OnValueChanged = (Il2CppSystem.Action)ValueChanged; + toggleOption.OnValueChanged = (Action)ValueChanged; OptionBehaviour = toggleOption; @@ -76,7 +102,7 @@ public class ModdedToggleOption : ModdedOption ModdedOptionsManager.AddSettingsChangeMessage( HudManager.Instance.Notifier, StringName, - newValue ? "On" : "Off", + (newValue ? OnValue : OffValue).Translate(), Configuration.PopUpTextColor, Configuration.PopUpIconTmp, false); diff --git a/MiraAPI/HnsReimplemented/HideAndSeekHudHelper.cs b/MiraAPI/HnsReimplemented/HideAndSeekHudHelper.cs index 2f7d67a..89347e0 100644 --- a/MiraAPI/HnsReimplemented/HideAndSeekHudHelper.cs +++ b/MiraAPI/HnsReimplemented/HideAndSeekHudHelper.cs @@ -126,7 +126,7 @@ public sealed class HideAndSeekHudHelper(nint cppPtr) : MonoBehaviour(cppPtr) currentFinalHideTime = totalFinalHideTime; if (timerBar != null) { - Object.Destroy(timerBar); + Destroy(timerBar); } finalHideAlertSfx = GameManagerCreator.Instance.HideAndSeekManagerPrefab.FinalHideAlertSFX; diff --git a/MiraAPI/Hud/MiraHudHelper.cs b/MiraAPI/Hud/MiraHudHelper.cs index f2d1d4b..27c1968 100644 --- a/MiraAPI/Hud/MiraHudHelper.cs +++ b/MiraAPI/Hud/MiraHudHelper.cs @@ -151,7 +151,7 @@ public sealed class MiraHudHelper(nint cppPtr) : MonoBehaviour(cppPtr) var oldPos = settingsButton.transform.localPosition; settingsButton.transform.localPosition = new Vector3(oldPos.x, oldPos.y, -100); var chatButton = instance.Chat.chatButton.gameObject; - ClonedChatButton = Object.Instantiate(chatButton, chatButton.transform.parent); + ClonedChatButton = Instantiate(chatButton, chatButton.transform.parent); ClonedChatButton.SetActive(false); instance.Chat.chatButtonAspectPosition = ClonedChatButton.GetComponent(); chatButton.GetComponent().Destroy(); 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..1ef854b 100644 --- a/MiraAPI/Patches/Options/SettingPatches.cs +++ b/MiraAPI/Patches/Options/SettingPatches.cs @@ -46,14 +46,14 @@ public static class SettingPatches } else { + var part = value.ToString(__instance.FormatString, NumberFormatInfo.InvariantInfo); result = suffix switch { - 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) + "%", - _ => TranslationController.Instance.GetString( - StringNames.GameSecondsAbbrev, - (Il2CppSystem.Object[])[value.ToString(__instance.FormatString, CultureInfo.InvariantCulture)]), + MiraNumberSuffixes.None => part, + MiraNumberSuffixes.Multiplier => part + "x", + MiraNumberSuffixes.Percent => part + "%", + MiraNumberSuffixes.Distance => string.Concat(part, " ", MiraApiPlugin.DistanceSuffix.Value), + _ => TranslationController.Instance.GetString(StringNames.GameSecondsAbbrev, part), }; } } @@ -63,14 +63,14 @@ public static class SettingPatches } else { + var part = value.ToString(__instance.FormatString, NumberFormatInfo.InvariantInfo); result = suffix switch { - 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) + "%", - _ => TranslationController.Instance.GetString( - StringNames.GameSecondsAbbrev, - (Il2CppSystem.Object[])[value.ToString(__instance.FormatString, CultureInfo.InvariantCulture)]), + MiraNumberSuffixes.None => part, + MiraNumberSuffixes.Multiplier => part + "x", + MiraNumberSuffixes.Percent => part + "%", + MiraNumberSuffixes.Distance => string.Concat(part, " ", MiraApiPlugin.DistanceSuffix.Value), + _ => TranslationController.Instance.GetString(StringNames.GameSecondsAbbrev, part), }; } diff --git a/MiraAPI/PluginLoading/MiraPluginManager.cs b/MiraAPI/PluginLoading/MiraPluginManager.cs index b46d155..f021c3f 100644 --- a/MiraAPI/PluginLoading/MiraPluginManager.cs +++ b/MiraAPI/PluginLoading/MiraPluginManager.cs @@ -313,9 +313,12 @@ public sealed class MiraPluginManager foreach (var property in type.GetProperties()) { + if (property.GetCustomAttribute() != null) + continue; + if (property.GetMethod?.IsStatic == true) { - Error($"Option property {property.Name} in {type.Name} must not be static."); + Error($"Non-ignored option property {property.Name} in {type.Name} must not be static."); continue; } @@ -325,6 +328,12 @@ public sealed class MiraPluginManager continue; } + if (property.PropertyType.IsAssignableTo(typeof(IModdedOptionList))) + { + ModdedOptionsManager.RegisterPropertyOptionList(type, property, pluginInfo); + continue; + } + var attribute = property.GetCustomAttribute(); if (attribute != null) { @@ -332,11 +341,6 @@ public sealed class MiraPluginManager continue; } - if (property.PropertyType.IsAssignableTo(typeof(IModdedOptionList))) - { - ModdedOptionsManager.RegisterPropertyOptionList(type, property, pluginInfo); - } - var listAttr = property.GetCustomAttribute(); if (listAttr != null) { 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/Resources/Locale/en_US.xml b/MiraAPI/Resources/Locale/en_US.xml index f09f71d..9073a6f 100644 --- a/MiraAPI/Resources/Locale/en_US.xml +++ b/MiraAPI/Resources/Locale/en_US.xml @@ -62,7 +62,7 @@ Outlive the timer or eliminate the crew. Options Player List Game Settings - Search... + Search... Filter Grouping Order @@ -71,4 +71,12 @@ Outlive the timer or eliminate the crew. Previous Next No Results Found + On + Off + Enabled + Disabled + Active + Inactive + Activated + Deactivated diff --git a/MiraAPI/Roles/ICustomRole.cs b/MiraAPI/Roles/ICustomRole.cs index 88b1f85..26dd0cf 100644 --- a/MiraAPI/Roles/ICustomRole.cs +++ b/MiraAPI/Roles/ICustomRole.cs @@ -339,7 +339,7 @@ public interface ICustomRole : IOptionable /// /// Gets the function that determines whether the role should be toggled on or off in the game settings. /// - Func VisibleInSettings => () => true; + Func VisibleInSettings => DelegateUtilities.AlwaysTrue; /// /// Gets the list of abilities or other similar information to display below the text of a role's wiki page. diff --git a/MiraAPI/Utilities/DelegateUtilities.cs b/MiraAPI/Utilities/DelegateUtilities.cs new file mode 100644 index 0000000..2ac4acf --- /dev/null +++ b/MiraAPI/Utilities/DelegateUtilities.cs @@ -0,0 +1,19 @@ +using System; + +namespace MiraAPI.Utilities; + +/// +/// A utility class for using delegates. +/// +public static class DelegateUtilities +{ + /// + /// Gets a Func<bool> that always returns true. + /// + public static Func AlwaysTrue { get; } = () => true; + + /// + /// Gets a Func<bool> that always returns false. + /// + public static Func AlwaysFalse { get; } = () => false; +} diff --git a/MiraAPI/Utilities/MiraNumberSuffixes.cs b/MiraAPI/Utilities/MiraNumberSuffixes.cs index 2f55226..d434814 100644 --- a/MiraAPI/Utilities/MiraNumberSuffixes.cs +++ b/MiraAPI/Utilities/MiraNumberSuffixes.cs @@ -24,4 +24,9 @@ public enum MiraNumberSuffixes /// Percentage suffix (%). /// Percent, + + /// + /// Distance suffix (u). + /// + Distance, }