]> git.smokeofanarchy.ru Git - space-station-14.git/commitdiff
De-enumify humanoid species skin colours (#39175)
authorpathetic meowmeow <uhhadd@gmail.com>
Sun, 14 Sep 2025 05:30:17 +0000 (01:30 -0400)
committerGitHub <noreply@github.com>
Sun, 14 Sep 2025 05:30:17 +0000 (22:30 -0700)
* De-enumify humanoid species skin colours

* Change index to resolve

---------

Co-authored-by: Princess Cheeseballs <66055347+Pronana@users.noreply.github.com>
Content.Client/Lobby/UI/HumanoidProfileEditor.xaml.cs
Content.Shared/Humanoid/HumanoidCharacterAppearance.cs
Content.Shared/Humanoid/Prototypes/SpeciesPrototype.cs
Content.Shared/Humanoid/SharedHumanoidAppearanceSystem.cs
Content.Shared/Humanoid/SkinColor.cs [deleted file]
Content.Shared/Humanoid/SkinColorationPrototype.cs [new file with mode: 0644]
Content.Tests/Shared/Preferences/Humanoid/SkinTonesTest.cs
Resources/Prototypes/Species/skin_colorations.yml [new file with mode: 0644]

index 52dba841d06800ce9c69a71992279f1b683b0a58..dfdfece979adaf361233d2c7f64e2d9f984aae12 100644 (file)
@@ -1088,10 +1088,11 @@ namespace Content.Client.Lobby.UI
             if (Profile is null) return;
 
             var skin = _prototypeManager.Index<SpeciesPrototype>(Profile.Species).SkinColoration;
+            var strategy = _prototypeManager.Index(skin).Strategy;
 
-            switch (skin)
+            switch (strategy.InputType)
             {
-                case HumanoidSkinColor.HumanToned:
+                case SkinColorationStrategyInput.Unary:
                 {
                     if (!Skin.Visible)
                     {
@@ -1099,25 +1100,14 @@ namespace Content.Client.Lobby.UI
                         RgbSkinColorContainer.Visible = false;
                     }
 
-                    var color = SkinColor.HumanSkinTone((int) Skin.Value);
+                    var color = strategy.FromUnary(Skin.Value);
 
                     Markings.CurrentSkinColor = color;
-                    Profile = Profile.WithCharacterAppearance(Profile.Appearance.WithSkinColor(color));//
-                    break;
-                }
-                case HumanoidSkinColor.Hues:
-                {
-                    if (!RgbSkinColorContainer.Visible)
-                    {
-                        Skin.Visible = false;
-                        RgbSkinColorContainer.Visible = true;
-                    }
+                    Profile = Profile.WithCharacterAppearance(Profile.Appearance.WithSkinColor(color));
 
-                    Markings.CurrentSkinColor = _rgbSkinColorSelector.Color;
-                    Profile = Profile.WithCharacterAppearance(Profile.Appearance.WithSkinColor(_rgbSkinColorSelector.Color));
                     break;
                 }
-                case HumanoidSkinColor.TintedHues:
+                case SkinColorationStrategyInput.Color:
                 {
                     if (!RgbSkinColorContainer.Visible)
                     {
@@ -1125,24 +1115,11 @@ namespace Content.Client.Lobby.UI
                         RgbSkinColorContainer.Visible = true;
                     }
 
-                    var color = SkinColor.TintedHues(_rgbSkinColorSelector.Color);
+                    var color = strategy.ClosestSkinColor(_rgbSkinColorSelector.Color);
 
                     Markings.CurrentSkinColor = color;
                     Profile = Profile.WithCharacterAppearance(Profile.Appearance.WithSkinColor(color));
-                    break;
-                }
-                case HumanoidSkinColor.VoxFeathers:
-                {
-                    if (!RgbSkinColorContainer.Visible)
-                    {
-                        Skin.Visible = false;
-                        RgbSkinColorContainer.Visible = true;
-                    }
-
-                    var color = SkinColor.ClosestVoxColor(_rgbSkinColorSelector.Color);
 
-                    Markings.CurrentSkinColor = color;
-                    Profile = Profile.WithCharacterAppearance(Profile.Appearance.WithSkinColor(color));
                     break;
                 }
             }
@@ -1321,10 +1298,11 @@ namespace Content.Client.Lobby.UI
                 return;
 
             var skin = _prototypeManager.Index<SpeciesPrototype>(Profile.Species).SkinColoration;
+            var strategy = _prototypeManager.Index(skin).Strategy;
 
-            switch (skin)
+            switch (strategy.InputType)
             {
-                case HumanoidSkinColor.HumanToned:
+                case SkinColorationStrategyInput.Unary:
                 {
                     if (!Skin.Visible)
                     {
@@ -1332,11 +1310,11 @@ namespace Content.Client.Lobby.UI
                         RgbSkinColorContainer.Visible = false;
                     }
 
-                    Skin.Value = SkinColor.HumanSkinToneFromColor(Profile.Appearance.SkinColor);
+                    Skin.Value = strategy.ToUnary(Profile.Appearance.SkinColor);
 
                     break;
                 }
-                case HumanoidSkinColor.Hues:
+                case SkinColorationStrategyInput.Color:
                 {
                     if (!RgbSkinColorContainer.Visible)
                     {
@@ -1344,36 +1322,11 @@ namespace Content.Client.Lobby.UI
                         RgbSkinColorContainer.Visible = true;
                     }
 
-                    // set the RGB values to the direct values otherwise
-                    _rgbSkinColorSelector.Color = Profile.Appearance.SkinColor;
-                    break;
-                }
-                case HumanoidSkinColor.TintedHues:
-                {
-                    if (!RgbSkinColorContainer.Visible)
-                    {
-                        Skin.Visible = false;
-                        RgbSkinColorContainer.Visible = true;
-                    }
-
-                    // set the RGB values to the direct values otherwise
-                    _rgbSkinColorSelector.Color = Profile.Appearance.SkinColor;
-                    break;
-                }
-                case HumanoidSkinColor.VoxFeathers:
-                {
-                    if (!RgbSkinColorContainer.Visible)
-                    {
-                        Skin.Visible = false;
-                        RgbSkinColorContainer.Visible = true;
-                    }
-
-                    _rgbSkinColorSelector.Color = SkinColor.ClosestVoxColor(Profile.Appearance.SkinColor);
+                    _rgbSkinColorSelector.Color = strategy.ClosestSkinColor(Profile.Appearance.SkinColor);
 
                     break;
                 }
             }
-
         }
 
         public void UpdateSpeciesGuidebookIcon()
index 66f910836538f6c5647b29cc283577bc89f4dc7d..583341c815a33ae1b16ba48fada9d0576cc62a9f 100644 (file)
@@ -1,4 +1,5 @@
 using System.Linq;
+using System.Numerics;
 using Content.Shared.Humanoid.Markings;
 using Content.Shared.Humanoid.Prototypes;
 using Robust.Shared.Prototypes;
@@ -27,7 +28,7 @@ public sealed partial class HumanoidCharacterAppearance : ICharacterAppearance,
     public Color EyeColor { get; set; } = Color.Black;
 
     [DataField]
-    public Color SkinColor { get; set; } = Humanoid.SkinColor.ValidHumanSkinTone;
+    public Color SkinColor { get; set; } = Color.FromHsv(new Vector4(0.07f, 0.2f, 1f, 1f));
 
     [DataField]
     public List<Marking> Markings { get; set; } = new();
@@ -92,14 +93,13 @@ public sealed partial class HumanoidCharacterAppearance : ICharacterAppearance,
 
     public static HumanoidCharacterAppearance DefaultWithSpecies(string species)
     {
-        var speciesPrototype = IoCManager.Resolve<IPrototypeManager>().Index<SpeciesPrototype>(species);
-        var skinColor = speciesPrototype.SkinColoration switch
+        var protoMan = IoCManager.Resolve<IPrototypeManager>();
+        var speciesPrototype = protoMan.Index<SpeciesPrototype>(species);
+        var skinColoration = protoMan.Index(speciesPrototype.SkinColoration).Strategy;
+        var skinColor = skinColoration.InputType switch
         {
-            HumanoidSkinColor.HumanToned => Humanoid.SkinColor.HumanSkinTone(speciesPrototype.DefaultHumanSkinTone),
-            HumanoidSkinColor.Hues => speciesPrototype.DefaultSkinTone,
-            HumanoidSkinColor.TintedHues => Humanoid.SkinColor.TintedHues(speciesPrototype.DefaultSkinTone),
-            HumanoidSkinColor.VoxFeathers => Humanoid.SkinColor.ClosestVoxColor(speciesPrototype.DefaultSkinTone),
-            _ => Humanoid.SkinColor.ValidHumanSkinTone,
+            SkinColorationStrategyInput.Unary => skinColoration.FromUnary(speciesPrototype.DefaultHumanSkinTone),
+            SkinColorationStrategyInput.Color => skinColoration.ClosestSkinColor(speciesPrototype.DefaultSkinTone),
         };
 
         return new(
@@ -147,23 +147,15 @@ public sealed partial class HumanoidCharacterAppearance : ICharacterAppearance,
 
         var newEyeColor = random.Pick(RealisticEyeColors);
 
-        var skinType = IoCManager.Resolve<IPrototypeManager>().Index<SpeciesPrototype>(species).SkinColoration;
+        var protoMan = IoCManager.Resolve<IPrototypeManager>();
+        var skinType = protoMan.Index<SpeciesPrototype>(species).SkinColoration;
+        var strategy = protoMan.Index(skinType).Strategy;
 
-        var newSkinColor = new Color(random.NextFloat(1), random.NextFloat(1), random.NextFloat(1), 1);
-        switch (skinType)
+        var newSkinColor = strategy.InputType switch
         {
-            case HumanoidSkinColor.HumanToned:
-                newSkinColor = Humanoid.SkinColor.HumanSkinTone(random.Next(0, 101));
-                break;
-            case HumanoidSkinColor.Hues:
-                break;
-            case HumanoidSkinColor.TintedHues:
-                newSkinColor = Humanoid.SkinColor.ValidTintedHuesSkinTone(newSkinColor);
-                break;
-            case HumanoidSkinColor.VoxFeathers:
-                newSkinColor = Humanoid.SkinColor.ProportionalVoxColor(newSkinColor);
-                break;
-        }
+            SkinColorationStrategyInput.Unary => strategy.FromUnary(random.NextFloat(0f, 100f)),
+            SkinColorationStrategyInput.Color => strategy.ClosestSkinColor(new Color(random.NextFloat(1), random.NextFloat(1), random.NextFloat(1), 1)),
+        };
 
         return new HumanoidCharacterAppearance(newHairStyle, newHairColor, newFacialHairStyle, newHairColor, newEyeColor, newSkinColor, new ());
 
@@ -207,10 +199,8 @@ public sealed partial class HumanoidCharacterAppearance : ICharacterAppearance,
             markingSet = new MarkingSet(appearance.Markings, speciesProto.MarkingPoints, markingManager, proto);
             markingSet.EnsureValid(markingManager);
 
-            if (!Humanoid.SkinColor.VerifySkinColor(speciesProto.SkinColoration, skinColor))
-            {
-                skinColor = Humanoid.SkinColor.ValidSkinTone(speciesProto.SkinColoration, skinColor);
-            }
+            var strategy = proto.Index(speciesProto.SkinColoration).Strategy;
+            skinColor = strategy.EnsureVerified(skinColor);
 
             markingSet.EnsureSpecies(species, skinColor, markingManager);
             markingSet.EnsureSexes(sex, markingManager);
index 0c63a88d5b3926ef0d601bfc32a6b32c0dd47319..a23ecdfc535da830305e179952f6d0ca9c54d781 100644 (file)
@@ -81,7 +81,7 @@ public sealed partial class SpeciesPrototype : IPrototype
     /// Method of skin coloration used by the species.
     /// </summary>
     [DataField(required: true)]
-    public HumanoidSkinColor SkinColoration { get; private set; }
+    public ProtoId<SkinColorationPrototype> SkinColoration { get; private set; }
 
     [DataField]
     public ProtoId<LocalizedDatasetPrototype> MaleFirstNames { get; private set; } = "NamesFirstMale";
index 7a22c0c29e8e6f2e71e8c754e62682b23f58fa9d..e88b99b5939ac0287c3cfcd7d98f0c61f8ad117e 100644 (file)
@@ -297,9 +297,10 @@ public abstract class SharedHumanoidAppearanceSystem : EntitySystem
             return;
         }
 
-        if (verify && !SkinColor.VerifySkinColor(species.SkinColoration, skinColor))
+        if (verify && _proto.Resolve(species.SkinColoration, out var index))
         {
-            skinColor = SkinColor.ValidSkinTone(species.SkinColoration, skinColor);
+            var strategy = index.Strategy;
+            skinColor = strategy.EnsureVerified(skinColor);
         }
 
         humanoid.SkinColor = skinColor;
diff --git a/Content.Shared/Humanoid/SkinColor.cs b/Content.Shared/Humanoid/SkinColor.cs
deleted file mode 100644 (file)
index d4d5268..0000000
+++ /dev/null
@@ -1,261 +0,0 @@
-using System.Numerics;
-using System.Security.Cryptography;
-using Microsoft.VisualBasic.CompilerServices;
-
-namespace Content.Shared.Humanoid;
-
-public static class SkinColor
-{
-    public const float MaxTintedHuesSaturation = 0.1f;
-    public const float MinTintedHuesLightness = 0.85f;
-
-    public const float MinHuesLightness = 0.175f;
-
-    public const float MinFeathersHue = 29f / 360;
-    public const float MaxFeathersHue = 174f / 360;
-    public const float MinFeathersSaturation = 20f / 100;
-    public const float MaxFeathersSaturation = 88f / 100;
-    public const float MinFeathersValue = 36f / 100;
-    public const float MaxFeathersValue = 55f / 100;
-
-    public static Color ValidHumanSkinTone => Color.FromHsv(new Vector4(0.07f, 0.2f, 1f, 1f));
-
-    /// <summary>
-    ///     Turn a color into a valid tinted hue skin tone.
-    /// </summary>
-    /// <param name="color">The color to validate</param>
-    /// <returns>Validated tinted hue skin tone</returns>
-    public static Color ValidTintedHuesSkinTone(Color color)
-    {
-        return TintedHues(color);
-    }
-
-    /// <summary>
-    ///     Get a human skin tone based on a scale of 0 to 100. The value is clamped between 0 and 100.
-    /// </summary>
-    /// <param name="tone">Skin tone. Valid range is 0 to 100, inclusive. 0 is gold/yellowish, 100 is dark brown.</param>
-    /// <returns>A human skin tone.</returns>
-    public static Color HumanSkinTone(int tone)
-    {
-        // 0 - 100, 0 being gold/yellowish and 100 being dark
-        // HSV based
-        //
-        // 0 - 20 changes the hue
-        // 20 - 100 changes the value
-        // 0 is 45 - 20 - 100
-        // 20 is 25 - 20 - 100
-        // 100 is 25 - 100 - 20
-
-        tone = Math.Clamp(tone, 0, 100);
-
-        var rangeOffset = tone - 20;
-
-        float hue = 25;
-        float sat = 20;
-        float val = 100;
-
-        if (rangeOffset <= 0)
-        {
-            hue += Math.Abs(rangeOffset);
-        }
-        else
-        {
-            sat += rangeOffset;
-            val -= rangeOffset;
-        }
-
-        var color = Color.FromHsv(new Vector4(hue / 360, sat / 100, val / 100, 1.0f));
-
-        return color;
-    }
-
-    /// <summary>
-    ///     Gets a human skin tone from a given color.
-    /// </summary>
-    /// <param name="color"></param>
-    /// <returns></returns>
-    /// <remarks>
-    ///     Does not cause an exception if the color is not originally from the human color range.
-    ///     Instead, it will return the approximation of the skin tone value.
-    /// </remarks>
-    public static float HumanSkinToneFromColor(Color color)
-    {
-        var hsv = Color.ToHsv(color);
-        // check for hue/value first, if hue is lower than this percentage
-        // and value is 1.0
-        // then it'll be hue
-        if (Math.Clamp(hsv.X, 25f / 360f, 1) > 25f / 360f
-            && hsv.Z == 1.0)
-        {
-            return Math.Abs(45 - (hsv.X * 360));
-        }
-        // otherwise it'll directly be the saturation
-        else
-        {
-            return hsv.Y * 100;
-        }
-    }
-
-    /// <summary>
-    ///     Verify if a color is in the human skin tone range.
-    /// </summary>
-    /// <param name="color">The color to verify</param>
-    /// <returns>True if valid, false otherwise.</returns>
-    public static bool VerifyHumanSkinTone(Color color)
-    {
-        var colorValues = Color.ToHsv(color);
-
-        var hue = Math.Round(colorValues.X * 360f);
-        var sat = Math.Round(colorValues.Y * 100f);
-        var val = Math.Round(colorValues.Z * 100f);
-        // rangeOffset makes it so that this value
-        // is 25 <= hue <= 45
-        if (hue < 25 || hue > 45)
-        {
-            return false;
-        }
-
-        // rangeOffset makes it so that these two values
-        // are 20 <= sat <= 100 and 20 <= val <= 100
-        // where saturation increases to 100 and value decreases to 20
-        if (sat < 20 || val < 20)
-        {
-            return false;
-        }
-
-        return true;
-    }
-
-    /// <summary>
-    ///     Convert a color to the 'tinted hues' skin tone type.
-    /// </summary>
-    /// <param name="color">Color to convert</param>
-    /// <returns>Tinted hue color</returns>
-    public static Color TintedHues(Color color)
-    {
-        var newColor = Color.ToHsl(color);
-        newColor.Y *= MaxTintedHuesSaturation;
-        newColor.Z = MathHelper.Lerp(MinTintedHuesLightness, 1f, newColor.Z);
-
-        return Color.FromHsv(newColor);
-    }
-
-    /// <summary>
-    ///     Verify if this color is a valid tinted hue color type, or not.
-    /// </summary>
-    /// <param name="color">The color to verify</param>
-    /// <returns>True if valid, false otherwise</returns>
-    public static bool VerifyTintedHues(Color color)
-    {
-        // tinted hues just ensures saturation is always .1, or 10% saturation at all times
-        return Color.ToHsl(color).Y <= MaxTintedHuesSaturation && Color.ToHsl(color).Z >= MinTintedHuesLightness;
-    }
-
-    /// <summary>
-    ///     Converts a Color proportionally to the allowed vox color range.
-    ///     Will NOT preserve the specific input color even if it is within the allowed vox color range.
-    /// </summary>
-    /// <param name="color">Color to convert</param>
-    /// <returns>Vox feather coloration</returns>
-    public static Color ProportionalVoxColor(Color color)
-    {
-        var newColor = Color.ToHsv(color);
-
-        newColor.X = newColor.X * (MaxFeathersHue - MinFeathersHue) + MinFeathersHue;
-        newColor.Y = newColor.Y * (MaxFeathersSaturation - MinFeathersSaturation) + MinFeathersSaturation;
-        newColor.Z = newColor.Z * (MaxFeathersValue - MinFeathersValue) + MinFeathersValue;
-
-        return Color.FromHsv(newColor);
-    }
-
-    // /// <summary>
-    // ///      Ensures the input Color is within the allowed vox color range.
-    // /// </summary>
-    // /// <param name="color">Color to convert</param>
-    // /// <returns>The same Color if it was within the allowed range, or the closest matching Color otherwise</returns>
-    public static Color ClosestVoxColor(Color color)
-    {
-        var hsv = Color.ToHsv(color);
-
-        hsv.X = Math.Clamp(hsv.X, MinFeathersHue, MaxFeathersHue);
-        hsv.Y = Math.Clamp(hsv.Y, MinFeathersSaturation, MaxFeathersSaturation);
-        hsv.Z = Math.Clamp(hsv.Z, MinFeathersValue, MaxFeathersValue);
-
-        return Color.FromHsv(hsv);
-    }
-
-    /// <summary>
-    ///     Verify if this color is a valid vox feather coloration, or not.
-    /// </summary>
-    /// <param name="color">The color to verify</param>
-    /// <returns>True if valid, false otherwise</returns>
-    public static bool VerifyVoxFeathers(Color color)
-    {
-        var colorHsv = Color.ToHsv(color);
-
-        if (colorHsv.X < MinFeathersHue || colorHsv.X > MaxFeathersHue)
-            return false;
-
-        if (colorHsv.Y < MinFeathersSaturation || colorHsv.Y > MaxFeathersSaturation)
-            return false;
-
-        if (colorHsv.Z < MinFeathersValue || colorHsv.Z > MaxFeathersValue)
-            return false;
-
-        return true;
-    }
-
-    /// <summary>
-    ///     This takes in a color, and returns a color guaranteed to be above MinHuesLightness
-    /// </summary>
-    /// <param name="color"></param>
-    /// <returns>Either the color as-is if it's above MinHuesLightness, or the color with luminosity increased above MinHuesLightness</returns>
-    public static Color MakeHueValid(Color color)
-    {
-        var manipulatedColor = Color.ToHsv(color);
-        manipulatedColor.Z = Math.Max(manipulatedColor.Z, MinHuesLightness);
-        return Color.FromHsv(manipulatedColor);
-    }
-
-    /// <summary>
-    ///     Verify if this color is above a minimum luminosity
-    /// </summary>
-    /// <param name="color"></param>
-    /// <returns>True if valid, false if not</returns>
-    public static bool VerifyHues(Color color)
-    {
-        return Color.ToHsv(color).Z >= MinHuesLightness;
-    }
-
-    public static bool VerifySkinColor(HumanoidSkinColor type, Color color)
-    {
-        return type switch
-        {
-            HumanoidSkinColor.HumanToned => VerifyHumanSkinTone(color),
-            HumanoidSkinColor.TintedHues => VerifyTintedHues(color),
-            HumanoidSkinColor.Hues => VerifyHues(color),
-            HumanoidSkinColor.VoxFeathers => VerifyVoxFeathers(color),
-            _ => false,
-        };
-    }
-
-    public static Color ValidSkinTone(HumanoidSkinColor type, Color color)
-    {
-        return type switch
-        {
-            HumanoidSkinColor.HumanToned => ValidHumanSkinTone,
-            HumanoidSkinColor.TintedHues => ValidTintedHuesSkinTone(color),
-            HumanoidSkinColor.Hues => MakeHueValid(color),
-            HumanoidSkinColor.VoxFeathers => ClosestVoxColor(color),
-            _ => color
-        };
-    }
-}
-
-public enum HumanoidSkinColor : byte
-{
-    HumanToned,
-    Hues,
-    VoxFeathers, // Vox feathers are limited to a specific color range
-    TintedHues, //This gives a color tint to a humanoid's skin (10% saturation with full hue range).
-}
diff --git a/Content.Shared/Humanoid/SkinColorationPrototype.cs b/Content.Shared/Humanoid/SkinColorationPrototype.cs
new file mode 100644 (file)
index 0000000..e37265c
--- /dev/null
@@ -0,0 +1,302 @@
+using System.Numerics;
+using JetBrains.Annotations;
+using Robust.Shared.Prototypes;
+using Robust.Shared.Serialization;
+
+namespace Content.Shared.Humanoid;
+
+/// <summary>
+/// A prototype containing a SkinColorationStrategy
+/// </summary>
+[Prototype]
+public sealed partial class SkinColorationPrototype : IPrototype
+{
+    [IdDataField]
+    public string ID { get; private set; } = default!;
+
+    /// <summary>
+    /// The skin coloration strategy specified by this prototype
+    /// </summary>
+    [DataField(required: true)]
+    public ISkinColorationStrategy Strategy = default!;
+}
+
+/// <summary>
+/// The type of input taken by a <see cref="SkinColorationStrategy" />
+/// </summary>
+[Serializable, NetSerializable]
+public enum SkinColorationStrategyInput
+{
+    /// <summary>
+    /// A single floating point number from 0 to 100 (inclusive)
+    /// </summary>
+    Unary,
+
+    /// <summary>
+    /// A <see cref="Color" />
+    /// </summary>
+    Color,
+}
+
+/// <summary>
+/// Takes in the given <see cref="SkinColorationStrategyInput" /> and returns an adjusted Color
+/// </summary>
+public interface ISkinColorationStrategy
+{
+    /// <summary>
+    /// The type of input expected by the implementor; callers should consult InputType before calling the methods that require a given input
+    /// </summary>
+    SkinColorationStrategyInput InputType { get; }
+
+    /// <summary>
+    /// Returns whether or not the provided <see cref="Color" /> is within bounds of this strategy
+    /// </summary>
+    bool VerifySkinColor(Color color);
+
+    /// <summary>
+    /// Returns the closest skin color that this strategy would provide to the given <see cref="Color" />
+    /// </summary>
+    Color ClosestSkinColor(Color color);
+
+    /// <summary>
+    /// Returns the input if it passes <see cref="VerifySkinColor">, otherwise returns <see cref="ClosestSkinColor" />
+    /// </summary>
+    Color EnsureVerified(Color color)
+    {
+        if (VerifySkinColor(color))
+        {
+            return color;
+        }
+
+        return ClosestSkinColor(color);
+    }
+
+    /// <summary>
+    /// Returns a colour representation of the given unary input
+    /// </summary>
+    Color FromUnary(float unary)
+    {
+        throw new InvalidOperationException("This coloration strategy does not support unary input");
+    }
+
+    /// <summary>
+    /// Returns a colour representation of the given unary input
+    /// </summary>
+    float ToUnary(Color color)
+    {
+        throw new InvalidOperationException("This coloration strategy does not support unary input");
+    }
+}
+
+/// <summary>
+/// Unary coloration strategy that returns human skin tones, with 0 being lightest and 100 being darkest
+/// </summary>
+[DataDefinition]
+[Serializable, NetSerializable]
+public sealed partial class HumanTonedSkinColoration : ISkinColorationStrategy
+{
+    [DataField]
+    public Color ValidHumanSkinTone = Color.FromHsv(new Vector4(0.07f, 0.2f, 1f, 1f));
+
+    public SkinColorationStrategyInput InputType => SkinColorationStrategyInput.Unary;
+
+    public bool VerifySkinColor(Color color)
+    {
+        var colorValues = Color.ToHsv(color);
+
+        var hue = Math.Round(colorValues.X * 360f);
+        var sat = Math.Round(colorValues.Y * 100f);
+        var val = Math.Round(colorValues.Z * 100f);
+        // rangeOffset makes it so that this value
+        // is 25 <= hue <= 45
+        if (hue < 25f || hue > 45f)
+        {
+            return false;
+        }
+
+        // rangeOffset makes it so that these two values
+        // are 20 <= sat <= 100 and 20 <= val <= 100
+        // where saturation increases to 100 and value decreases to 20
+        if (sat < 20f || val < 20f)
+        {
+            return false;
+        }
+
+        return true;
+    }
+
+    public Color ClosestSkinColor(Color color)
+    {
+        return ValidHumanSkinTone;
+    }
+
+    public Color FromUnary(float color)
+    {
+        // 0 - 100, 0 being gold/yellowish and 100 being dark
+        // HSV based
+        //
+        // 0 - 20 changes the hue
+        // 20 - 100 changes the value
+        // 0 is 45 - 20 - 100
+        // 20 is 25 - 20 - 100
+        // 100 is 25 - 100 - 20
+
+        var tone = Math.Clamp(color, 0f, 100f);
+
+        var rangeOffset = tone - 20f;
+
+        var hue = 25f;
+        var sat = 20f;
+        var val = 100f;
+
+        if (rangeOffset <= 0)
+        {
+            hue += Math.Abs(rangeOffset);
+        }
+        else
+        {
+            sat += rangeOffset;
+            val -= rangeOffset;
+        }
+
+        return Color.FromHsv(new Vector4(hue / 360f, sat / 100f, val / 100f, 1.0f));
+    }
+
+    public float ToUnary(Color color)
+    {
+        var hsv = Color.ToHsv(color);
+        // check for hue/value first, if hue is lower than this percentage
+        // and value is 1.0
+        // then it'll be hue
+        if (Math.Clamp(hsv.X, 25f / 360f, 1) > 25f / 360f
+            && hsv.Z == 1.0)
+        {
+            return Math.Abs(45 - (hsv.X * 360));
+        }
+        // otherwise it'll directly be the saturation
+        else
+        {
+            return hsv.Y * 100;
+        }
+    }
+}
+
+/// <summary>
+/// Unary coloration strategy that clamps the color within the HSV colorspace
+/// </summary>
+[DataDefinition]
+[Serializable, NetSerializable]
+public sealed partial class ClampedHsvColoration : ISkinColorationStrategy
+{
+    /// <summary>
+    /// The (min, max) of the hue channel.
+    /// </summary>
+    [DataField]
+    public (float, float)? Hue;
+
+    /// <summary>
+    /// The (min, max) of the saturation channel.
+    /// </summary>
+    [DataField]
+    public (float, float)? Saturation;
+
+    /// <summary>
+    /// The (min, max) of the value channel.
+    /// </summary>
+    [DataField]
+    public (float, float)? Value;
+
+    public SkinColorationStrategyInput InputType => SkinColorationStrategyInput.Color;
+
+    public bool VerifySkinColor(Color color)
+    {
+        var hsv = Color.ToHsv(color);
+
+        if (Hue is (var minHue, var maxHue) && (hsv.X < minHue || hsv.X > maxHue))
+            return false;
+
+        if (Saturation is (var minSaturation, var maxSaturation) && (hsv.Y < minSaturation || hsv.Y > maxSaturation))
+            return false;
+
+        if (Value is (var minValue, var maxValue) && (hsv.Z < minValue || hsv.Z > maxValue))
+            return false;
+
+        return true;
+    }
+
+    public Color ClosestSkinColor(Color color)
+    {
+        var hsv = Color.ToHsv(color);
+
+        if (Hue is (var minHue, var maxHue))
+            hsv.X = Math.Clamp(hsv.X, minHue, maxHue);
+
+        if (Saturation is (var minSaturation, var maxSaturation))
+            hsv.Y = Math.Clamp(hsv.Y, minSaturation, maxSaturation);
+
+        if (Value is (var minValue, var maxValue))
+            hsv.Z = Math.Clamp(hsv.Z, minValue, maxValue);
+
+        return Color.FromHsv(hsv);
+    }
+}
+
+/// <summary>
+/// Unary coloration strategy that clamps the color within the HSL colorspace
+/// </summary>
+[DataDefinition]
+[Serializable, NetSerializable]
+public sealed partial class ClampedHslColoration : ISkinColorationStrategy
+{
+    /// <summary>
+    /// The (min, max) of the hue channel.
+    /// </summary>
+    [DataField]
+    public (float, float)? Hue;
+
+    /// <summary>
+    /// The (min, max) of the saturation channel.
+    /// </summary>
+    [DataField]
+    public (float, float)? Saturation;
+
+    /// <summary>
+    /// The (min, max) of the lightness channel.
+    /// </summary>
+    [DataField]
+    public (float, float)? Lightness;
+
+    public SkinColorationStrategyInput InputType => SkinColorationStrategyInput.Color;
+
+    public bool VerifySkinColor(Color color)
+    {
+        var hsl = Color.ToHsl(color);
+
+        if (Hue is (var minHue, var maxHue) && (hsl.X < minHue || hsl.X > maxHue))
+            return false;
+
+        if (Saturation is (var minSaturation, var maxSaturation) && (hsl.Y < minSaturation || hsl.Y > maxSaturation))
+            return false;
+
+        if (Lightness is (var minValue, var maxValue) && (hsl.Z < minValue || hsl.Z > maxValue))
+            return false;
+
+        return true;
+    }
+
+    public Color ClosestSkinColor(Color color)
+    {
+        var hsl = Color.ToHsl(color);
+
+        if (Hue is (var minHue, var maxHue))
+            hsl.X = Math.Clamp(hsl.X, minHue, maxHue);
+
+        if (Saturation is (var minSaturation, var maxSaturation))
+            hsl.Y = Math.Clamp(hsl.Y, minSaturation, maxSaturation);
+
+        if (Lightness is (var minValue, var maxValue))
+            hsl.Z = Math.Clamp(hsl.Z, minValue, maxValue);
+
+        return Color.FromHsl(hsl);
+    }
+}
index e13825ea28233cd0155443a81d0fc6392a714702..63cefac812b3a3bf2b6eaa33c2097db60b7c9fa6 100644 (file)
@@ -9,16 +9,20 @@ public sealed class SkinTonesTest
     [Test]
     public void TestHumanSkinToneValidity()
     {
+        var strategy = new HumanTonedSkinColoration();
+
         for (var i = 0; i <= 100; i++)
         {
-            var color = SkinColor.HumanSkinTone(i);
-            Assert.That(SkinColor.VerifyHumanSkinTone(color));
+            var color = strategy.FromUnary(i);
+            Assert.That(strategy.VerifySkinColor(color));
         }
     }
 
     [Test]
     public void TestDefaultSkinToneValid()
     {
-        Assert.That(SkinColor.VerifyHumanSkinTone(SkinColor.ValidHumanSkinTone));
+        var strategy = new HumanTonedSkinColoration();
+
+        Assert.That(strategy.VerifySkinColor(strategy.ValidHumanSkinTone));
     }
 }
diff --git a/Resources/Prototypes/Species/skin_colorations.yml b/Resources/Prototypes/Species/skin_colorations.yml
new file mode 100644 (file)
index 0000000..c4b7c7b
--- /dev/null
@@ -0,0 +1,21 @@
+- type: skinColoration
+  id: Hues
+  strategy: !type:ClampedHsvColoration
+    value: [0.175, 1]
+
+- type: skinColoration
+  id: TintedHues
+  strategy: !type:ClampedHslColoration
+    saturation: [0, 0.1]
+    lightness: [0.85, 1]
+
+- type: skinColoration
+  id: VoxFeathers
+  strategy: !type:ClampedHsvColoration
+    hue: [0.081, 0.48]
+    saturation: [0.2, 0.8]
+    value: [0.36, 0.55]
+
+- type: skinColoration
+  id: HumanToned
+  strategy: !type:HumanTonedSkinColoration {}