De-enumify humanoid species skin colours (#39175)

* De-enumify humanoid species skin colours

* Change index to resolve

---------

Co-authored-by: Princess Cheeseballs <66055347+Pronana@users.noreply.github.com>
This commit is contained in:
pathetic meowmeow
2025-09-14 01:30:17 -04:00
committed by GitHub
parent 2820882754
commit d9c24b3d10
8 changed files with 365 additions and 355 deletions

View 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);

View 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";

View 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;

View File

@@ -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).
}

View File

@@ -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);
}
}