diff --git a/MiraAPI/Patches/Options/GameOptionsMenuPatch.cs b/MiraAPI/Patches/Options/GameOptionsMenuPatch.cs index aca0f2e..b792767 100644 --- a/MiraAPI/Patches/Options/GameOptionsMenuPatch.cs +++ b/MiraAPI/Patches/Options/GameOptionsMenuPatch.cs @@ -202,7 +202,7 @@ public static class GameOptionsMenuPatch { categoryHeaderMasked.Background.color = group.GroupColor; categoryHeaderMasked.Divider.color = group.GroupColor; - categoryHeaderMasked.Title.color = group.GroupColor.GetAlternateColor(); + categoryHeaderMasked.Title.color = group.GroupColor.FindAlternateColor(); } categoryHeaderMasked.Background.size = new Vector2( @@ -232,10 +232,10 @@ public static class GameOptionsMenuPatch { if (group.GroupColor != Color.clear) { - renderer.color = group.GroupColor.GetAlternateColor(); + renderer.color = group.GroupColor.FindAlternateColor(); if (renderer.transform.parent.TryGetComponent(out var btn)) { - btn.interactableColor = group.GroupColor.GetAlternateColor(); + btn.interactableColor = group.GroupColor.FindAlternateColor(); btn.interactableHoveredColor = Color.white; } } diff --git a/MiraAPI/Patches/Options/LobbyViewPanePatches.cs b/MiraAPI/Patches/Options/LobbyViewPanePatches.cs index 9b2a934..5172ba0 100644 --- a/MiraAPI/Patches/Options/LobbyViewPanePatches.cs +++ b/MiraAPI/Patches/Options/LobbyViewPanePatches.cs @@ -340,7 +340,7 @@ public static class LobbyViewPanePatches viewSettingsInfoPanelRoleVariant.chanceTitle.color = viewSettingsInfoPanelRoleVariant.chanceBackground.color = viewSettingsInfoPanelRoleVariant.background.color = - customRole.RoleColor.GetAlternateColor(); + customRole.RoleColor.FindAlternateColor(); instance.settingsInfo.Add(viewSettingsInfoPanelRoleVariant.gameObject); num -= 0.664f; } diff --git a/MiraAPI/Patches/Options/RoleSettingMenuPatches.cs b/MiraAPI/Patches/Options/RoleSettingMenuPatches.cs index 126f0b5..b21f387 100644 --- a/MiraAPI/Patches/Options/RoleSettingMenuPatches.cs +++ b/MiraAPI/Patches/Options/RoleSettingMenuPatches.cs @@ -384,7 +384,7 @@ public static class RoleSettingMenuPatches } __instance.roleHeaderSprite.color = customRole.RoleColor; - __instance.roleHeaderText.color = customRole.RoleColor.GetAlternateColor(); + __instance.roleHeaderText.color = customRole.RoleColor.FindAlternateColor(); var categoryHeaderMasked = __instance.AdvancedRolesSettings.transform.Find("CategoryHeaderMasked").GetComponent(); @@ -465,7 +465,7 @@ public static class RoleSettingMenuPatches __instance.roleChances.Add(roleOptionSetting); roleOptionSetting.titleText.transform.localPosition = new Vector3(-0.5376f, -0.2923f, 0f); - roleOptionSetting.titleText.color = customRole.RoleColor.GetAlternateColor(); + roleOptionSetting.titleText.color = customRole.RoleColor.FindAlternateColor(); roleOptionSetting.titleText.horizontalAlignment = HorizontalAlignmentOptions.Left; if (GameSettingMenuPatches.SelectedMod is null || @@ -483,7 +483,7 @@ public static class RoleSettingMenuPatches var passiveButton = newButton.GetComponent(); passiveButton.OnClick = new ButtonClickedEvent(); - passiveButton.interactableColor = btnRend.color = customRole.RoleColor.GetAlternateColor(); + passiveButton.interactableColor = btnRend.color = customRole.RoleColor.FindAlternateColor(); passiveButton.interactableHoveredColor = Color.white; passiveButton.OnClick.AddListener((UnityAction)(() => { ChangeTab(role, __instance); })); diff --git a/MiraAPI/Utilities/ColorExtensions.cs b/MiraAPI/Utilities/ColorExtensions.cs new file mode 100644 index 0000000..74c5a22 --- /dev/null +++ b/MiraAPI/Utilities/ColorExtensions.cs @@ -0,0 +1,89 @@ +using UnityEngine; + +namespace MiraAPI.Utilities; + +/// +/// Extension methods for the Color struct. +/// +public static class ColorExtensions +{ + /// + /// Gets the relative luminance of a color (WCAG 2.1 specification). + /// + /// Color to calculate luminance for. + /// Relative luminance value. + public static float GetRelativeLuminance(this Color color) + { + var r = color.r <= 0.03928f ? color.r / 12.92f : Mathf.Pow((color.r + 0.055f) / 1.055f, 2.4f); + var g = color.g <= 0.03928f ? color.g / 12.92f : Mathf.Pow((color.g + 0.055f) / 1.055f, 2.4f); + var b = color.b <= 0.03928f ? color.b / 12.92f : Mathf.Pow((color.b + 0.055f) / 1.055f, 2.4f); + return 0.2126f * r + 0.7152f * g + 0.0722f * b; + } + + /// + /// Calculates the contrast ratio between two colors (WCAG 2.1 specification). + /// + /// First color. + /// Second color. + /// Contrast ratio between the two colors. + public static float GetContrastRatio(this Color colorA, Color colorB) + { + var luminanceA = colorA.GetRelativeLuminance(); + var luminanceB = colorB.GetRelativeLuminance(); + return (Mathf.Max(luminanceA, luminanceB) + 0.05f) / (Mathf.Min(luminanceA, luminanceB) + 0.05f); + } + + /// + /// Generates an accessible alternate color with sufficient contrast. + /// + /// Base color. + /// Target contrast ratio (default 4.5f for AA). + /// Alternate color with sufficient contrast. + public static Color FindAlternateColor(this Color color, float desiredRatio = 4.5f) + { + var lightColor = FindMinimumContrastColor(color, Color.white, desiredRatio); + var darkColor = FindMinimumContrastColor(color, Color.black, desiredRatio); + + return lightColor.GetContrastRatio(color) >= darkColor.GetContrastRatio(color) ? lightColor : darkColor; + } + + /// + /// Finds the color with the minimum contrast ratio to the target color. + /// + /// The base color to start from. + /// The target color to compare against. + /// The desired contrast ratio. + /// The color with the minimum contrast ratio to the target color. + public static Color FindMinimumContrastColor(Color baseColor, Color targetColor, float desiredRatio) + { + var baseLuminance = baseColor.GetRelativeLuminance(); + var isLightening = targetColor == Color.white; + + var targetLuminance = isLightening + ? desiredRatio * (baseLuminance + 0.05f) - 0.05f + : (baseLuminance + 0.05f) / desiredRatio - 0.05f; + + if (isLightening && targetLuminance > 1f) return Color.white; + if (!isLightening && targetLuminance < 0f) return Color.black; + + float low = 0f, high = 1f; + var bestColor = targetColor; + for (var i = 0; i < 10; i++) + { + var t = (low + high) / 2f; + var testColor = Color.Lerp(baseColor, targetColor, t); + var testLuminance = testColor.GetRelativeLuminance(); + + if ((isLightening && testLuminance >= targetLuminance) || (!isLightening && testLuminance <= targetLuminance)) + { + high = t; + bestColor = testColor; + } + else + { + low = t; + } + } + return bestColor; + } +} diff --git a/MiraAPI/Utilities/Extensions.cs b/MiraAPI/Utilities/Extensions.cs index a85da1d..47a05f7 100644 --- a/MiraAPI/Utilities/Extensions.cs +++ b/MiraAPI/Utilities/Extensions.cs @@ -245,6 +245,7 @@ public static class Extensions /// The original color. /// The amount to darken or lighten the original color by between 0.0 and 1.0. /// An alternate color that has been darkened or lightened. + [Obsolete("Use FindAlternateColor for WACG compliance.")] public static Color GetAlternateColor(this Color color, float amount = 0.45f) { return color.IsColorDark() ? LightenColor(color, amount) : DarkenColor(color, amount);