Blazor UI component code-behind for a character creator panel. Manages editable CharacterAppearanceData, UI state (tabs, preview, makeup, clothing, body/face blending), catalog loading, and save/restore actions used by the Razor layout.
using Sandbox.UI;
using System;
using System.Text.Json;
using System.Threading.Tasks;
namespace CharacterEditor;
/// <summary>Creator state and actions; layout is in CharacterCreatorPanel.razor.</summary>
public partial class CharacterCreatorPanel
{
/// <summary>Consumer-specific pages; built-in categories remain owned by Character Kit.</summary>
public IReadOnlyList<string> ExtraTabs { get; set; } = Array.Empty<string>();
public IReadOnlyList<string> HiddenTabs { get; set; } = Array.Empty<string>();
public RenderFragment<CharacterCreatorPanel> ExtraContent { get; set; }
public RenderFragment FooterContent { get; set; }
public Action StopPreview { get; set; }
public bool Disabled { get; set; }
public string ProductLabel { get; set; } = "Character Editor";
public string LocationLabel { get; set; } = "Studio";
public string Title { get; set; } = "Create your character";
public CharacterAppearanceData EditableAppearance => Appearance;
public SkinnedModelRenderer PreviewRenderer => Preview?.Body;
public string SelectedTab => Tab;
public string SaveLabel { get; set; } = "Save character";
public Func<string, CharacterAppearanceData, string> Saved { get; set; }
public Action Closed { get; set; }
public CharacterPreset InitialPreset { get; set; }
/// <summary>Opening page for a newly supplied draft; existing draft edits default to Face.</summary>
public string InitialTab { get; set; } = "Face";
CharacterPreset loadedPreset;
public CharacterAppearanceData DraftAppearance => Appearance.DeepCopy();
public string DraftCharacterName => CharacterName;
public void LoadDraft(string name, CharacterAppearanceData data) => LoadDraftOnTab(name, data, "Face");
void LoadDraftOnTab(string name, CharacterAppearanceData data, string initialTab)
{
CharacterName = name;
Appearance = data.DeepCopy();
ChosenPreset = null;
Tab = Tabs.Contains(initialTab) ? initialTab : Tabs.FirstOrDefault() ?? "Presets";
}
public bool AppearanceReady => CatalogReady && Preview?.Loading != true && CharacterAppearance.ValidateForApply(Appearance)is null;
IReadOnlyList<string> Tabs => new[]
{
"Presets",
"Face",
"Photos",
"Gender",
"Shape",
"Skin",
"Hair",
"Clothing",
"Makeup",
"Tattoos"
}.Where(x => !HiddenTabs.Contains(x) && (x != "Face" || !HiddenTabs.Contains("Face & body"))).Concat(ExtraTabs).Distinct().ToArray();
bool CloudWardrobeOpen;
string WardrobeSearch = "", WardrobeCategory = "Tops";
IEnumerable<WardrobeItem> VisibleWardrobe => ClothingResources.Available.Where(x => x.Category == WardrobeCategory && (string.IsNullOrWhiteSpace(WardrobeSearch) || x.Name.Contains(WardrobeSearch, StringComparison.OrdinalIgnoreCase))).OrderBy(x => x.Name);
void ChooseCloudClothing(WardrobeItem item)
{
WardrobeCategory = item.Category;
WardrobeSearch = "";
ChooseClothing(item);
Appearance.Version = 2;
ChosenPreset = null;
Feedback = null;
StateHasChanged();
}
void ChooseClothing(WardrobeItem item)
{
var clothing = ClothingResources.Get(item.Path);
if (clothing is not null && ClothingResources.IsHair(clothing))
Appearance.HairPath = Appearance.HairPath == item.Path || ClothingResources.Get(Appearance.HairPath) == clothing ? "" : item.Path;
else if (clothing is not null && ClothingResources.IsFacialHair(clothing))
Appearance.FacialHairPath = Appearance.FacialHairPath == item.Path || ClothingResources.Get(Appearance.FacialHairPath) == clothing ? "" : item.Path;
else
WardrobeCatalog.Toggle(Appearance, item);
Appearance.Version = 2;
ShowClothing = true;
ChosenPreset = null;
Feedback = null;
}
void ClearClothing()
{
Appearance.ClothingPaths = Array.Empty<string>();
Appearance.Version = 2;
ShowClothing = true;
ChosenPreset = null;
}
void ResetClothing()
{
Appearance.ClothingPaths = WardrobeCatalog.DefaultPaths.ToArray();
Appearance.Version = 2;
ShowClothing = true;
ChosenPreset = null;
}
CharacterAppearanceData Appearance = AppearanceCatalog.CreateDefault();
string CharacterName = "Rowan", PlaythroughName = "A new adventure", Tab = "Presets", ShapeCategory = "Body", ChosenPreset = "traveller", Feedback;
string BlendRegion = "overall", GenderRegion = "overall";
bool CatalogReady, LoadingCatalog, CustomHairColor, InspectTeeth;
string SkinPalette = "Human";
string MakeupLayer = "Lipstick";
bool CustomMakeup;
int SelectedMarking;
string MarkingSection = "Design";
CharacterMarkingData CurrentMarking => Appearance.Makeup.Markings.Count == 0 ? null : Appearance.Makeup.Markings[Math.Clamp(SelectedMarking, 0, Appearance.Makeup.Markings.Count - 1)];
MakeupLayerData CurrentMakeup => MakeupLayer == "Tattoo" ? CurrentMarking?.Ink ?? Appearance.Makeup.Tattoo : MakeupCatalog.Layer(Appearance.Makeup, MakeupLayer);
void ChooseMakeupLayer(string layer)
{
if (LoadingCatalog)
return;
MakeupLayer = layer;
if (layer == "Tattoo")
Appearance.Makeup.EditMarkings();
}
void SelectMarking(int index)
{
if (LoadingCatalog || index < 0 || index >= Appearance.Makeup.Markings.Count)
return;
SelectedMarking = index;
}
void AddMarking()
{
if (LoadingCatalog || Appearance.Makeup.Markings.Count >= MarkingCatalog.Maximum)
{
Feedback = "You can combine up to six marking layers.";
return;
}
var marking = new CharacterMarkingData();
MarkingCatalog.ResetPosition(marking);
Appearance.Makeup.Markings.Add(marking);
SelectedMarking = Appearance.Makeup.Markings.Count - 1;
MarkingSection = "Design";
ChangedCosmetics();
}
void DeleteMarking()
{
if (LoadingCatalog || CurrentMarking is null)
return;
Appearance.Makeup.Markings.Remove(CurrentMarking);
SelectedMarking = Math.Clamp(SelectedMarking, 0, Math.Max(0, Appearance.Makeup.Markings.Count - 1));
ChangedCosmetics();
}
string MarkingSymbol(string style) => style switch
{
"crescent" => "brightness_2",
"teardrop" => "water_drop",
"star" => "emergency",
"five-point-star" => "star",
"line" => "horizontal_rule",
"circle" => "radio_button_unchecked",
"dot" => "circle",
"wave" => "waves",
"heart" => "favorite",
"arrow" => "arrow_forward",
"lightning" => "bolt",
"triangle" => "change_history",
"diamond" => "diamond",
"rune" => "gesture",
"barbed-band" => "link",
_ => "block"
};
void ChooseMarkingStyle(string style)
{
if (LoadingCatalog || CurrentMarking is null)
return;
MarkingCatalog.ChangeStyle(CurrentMarking, style);
ChangedCosmetics();
}
void ChooseMarkingRegion(string region)
{
if (LoadingCatalog || CurrentMarking is null)
return;
MarkingCatalog.ChangeRegion(CurrentMarking, region);
ChangedCosmetics();
}
float MarkingValue(string control) => control switch
{
"Horizontal" => CurrentMarking.Horizontal,
"Vertical" => CurrentMarking.Vertical,
"Scale" => CurrentMarking.Scale,
"Spacing" => CurrentMarking.Spacing,
"Thickness" => CurrentMarking.Thickness,
"Length" => CurrentMarking.Length,
"Points" => CurrentMarking.Points,
_ => CurrentMarking.Rotation
};
void SetMarkingValue(string control, float value)
{
if (LoadingCatalog || CurrentMarking is null || !float.IsFinite(value))
return;
if (!MarkingCatalog.Controls(CurrentMarking.Style).Contains(control))
return;
value = Math.Clamp(value, control == "Points" ? 3 : 0, control == "Points" ? 12 : 1);
if (control == "Horizontal")
CurrentMarking.Horizontal = value;
else if (control == "Vertical")
CurrentMarking.Vertical = value;
else if (control == "Scale")
CurrentMarking.Scale = value;
else if (control == "Spacing")
CurrentMarking.Spacing = value;
else if (control == "Rotation")
CurrentMarking.Rotation = value;
else if (control == "Thickness")
CurrentMarking.Thickness = value;
else if (control == "Length")
CurrentMarking.Length = value;
else if (control == "Points")
CurrentMarking.Points = (int)MathF.Round(value);
ChangedCosmetics();
}
void ResetMarkingPlacement()
{
if (LoadingCatalog || CurrentMarking is null)
return;
MarkingCatalog.ResetPosition(CurrentMarking);
CurrentMarking.Scale = .5f;
CurrentMarking.Rotation = .5f;
CurrentMarking.Spacing = .5f;
CurrentMarking.Thickness = .5f;
CurrentMarking.Length = .5f;
CurrentMarking.Points = 5;
ChangedCosmetics();
}
void InspectMarking()
{
if (LoadingCatalog || CurrentMarking is null)
return;
var region = MarkingCatalog.Region(CurrentMarking.Region);
var center = MarkingCatalog.Focus(CurrentMarking);
var outward = MarkingCatalog.Outward(CurrentMarking);
Yaw = -MathF.Atan2(outward.y, outward.x) * 180 / MathF.PI;
SetZoom(region.Head ? 1.55f : 1.15f);
var rotated = Rotation.FromYaw(Yaw) * (Preview?.InspectionPoint(center, CurrentMarking.Region) ?? center);
var targetHeight = Preview?.InspectionHeight(Zoom) ?? 62 + 1.5f * MathF.Max(0, Zoom - 1);
PreviewPan = new(rotated.x, rotated.y, rotated.z - targetHeight);
PreviewPitch = 0;
}
void ChangedCosmetics()
{
ChosenPreset = null;
Feedback = null;
}
void ChooseNoMakeup()
{
if (LoadingCatalog)
return;
CurrentMakeup.Amount = 0;
if (MakeupLayer == "Eyeliner")
MakeupCatalog.Reset(Appearance.Makeup, "Eyeliner");
ChangedCosmetics();
}
void ChooseMakeupColor(Color color)
{
if (LoadingCatalog)
return;
CurrentMakeup.Color = color;
if (!CurrentMakeupActive)
CurrentMakeup.Amount = MakeupLayer == "Eyeliner" ? 1f : .65f;
ChangedCosmetics();
}
void SetMakeupAmount(float value)
{
if (LoadingCatalog || !float.IsFinite(value))
return;
CurrentMakeup.Amount = Math.Clamp(value, 0, 1);
ChangedCosmetics();
}
bool CurrentMakeupActive => MakeupLayer == "Eyeliner" ? Appearance.Makeup.EyelinerActive : CurrentMakeup.Amount > 0;
float EyelinerStrength(string part) => part == "Lower eyelid" ? Appearance.Makeup.EyelinerLowerStrength : part == "Wing" ? Appearance.Makeup.EyelinerWingStrength : Appearance.Makeup.EyelinerInnerStrength;
void SetEyelinerStrength(string part, float value)
{
if (LoadingCatalog || !float.IsFinite(value))
return;
value = Math.Clamp(value, 0, 1);
if (part == "Lower eyelid")
Appearance.Makeup.EyelinerLowerStrength = value;
else if (part == "Wing")
Appearance.Makeup.EyelinerWingStrength = value;
else
Appearance.Makeup.EyelinerInnerStrength = value;
ChangedCosmetics();
}
void PrepareAdjustableWing()
{
var makeup = Appearance.Makeup;
makeup.EyelinerWingHorizontal = makeup.WingTipHorizontal;
makeup.EyelinerWingVertical = makeup.WingTipVertical;
makeup.EyelinerWing = "wing";
}
void SetWingTip(bool vertical, float value)
{
if (LoadingCatalog || !float.IsFinite(value))
return;
PrepareAdjustableWing();
if (vertical)
Appearance.Makeup.EyelinerWingVertical = Math.Clamp(value, -1, 1);
else
Appearance.Makeup.EyelinerWingHorizontal = Math.Clamp(value, 0, 1);
ChangedCosmetics();
}
void SetWingThickness(float value)
{
if (LoadingCatalog || !float.IsFinite(value))
return;
PrepareAdjustableWing();
Appearance.Makeup.EyelinerWingThickness = Math.Clamp(value, 0, 1);
ChangedCosmetics();
}
void ChooseEyelinerWing(string wing)
{
if (LoadingCatalog)
return;
if (wing == "wing")
PrepareAdjustableWing();
else
Appearance.Makeup.EyelinerWing = "none";
if (wing != "none" && CurrentMakeup.Amount == 0)
CurrentMakeup.Amount = 1;
ChangedCosmetics();
}
float MakeupChannel(string channel) => channel == "Red" ? CurrentMakeup.Red : channel == "Green" ? CurrentMakeup.Green : CurrentMakeup.Blue;
void SetMakeupChannel(string channel, float value)
{
if (LoadingCatalog || !float.IsFinite(value))
return;
value = Math.Clamp(value, 0, 1);
if (channel == "Red")
CurrentMakeup.Red = value;
else if (channel == "Green")
CurrentMakeup.Green = value;
else
CurrentMakeup.Blue = value;
ChangedCosmetics();
}
void ClearMakeup()
{
if (LoadingCatalog)
return;
if (Tab == "Tattoos")
{
Appearance.Makeup.Markings.Clear();
Appearance.Makeup.TattooStyle = "none";
Appearance.Makeup.Tattoo = new();
SelectedMarking = 0;
}
else
{
var previous = Appearance.Makeup;
Appearance.Makeup = new()
{
Markings = previous.Markings,
TattooStyle = previous.TattooStyle,
Tattoo = previous.Tattoo
};
}
ChangedCosmetics();
}
bool Portrait = true, ShowClothing = true;
float Yaw, Zoom = CharacterCreatorPreview.FaceZoom;
CharacterCreatorPreview Preview;
Vector3 PreviewPan;
float PreviewPitch;
bool DynamicLook = true;
string BodyType => AppearanceCatalog.IsFemale(Appearance) ? "female" : "male";
IEnumerable<AppearanceOption> AvailableHair => AppearanceCatalog.Hair.Concat(ClothingResources.Available.Where(x => x.Category == "Hair").Select(x => new AppearanceOption(x.Path, x.Name, x.Path))).DistinctBy(x => x.ResourcePath);
IEnumerable<AppearanceOption> AvailableFacialHair => AppearanceCatalog.FacialHair.Concat(ClothingResources.Available.Where(x => x.Category == "Facial hair").Select(x => new AppearanceOption(x.Path, x.Name, x.Path))).DistinctBy(x => x.ResourcePath);
string HairLabel => AppearanceCatalog.Hair.FirstOrDefault(x => x.ResourcePath == Appearance.HairPath)?.Label ?? "Hair";
string FaceLabel(AppearanceOption option) => option.Label.Replace(" · male", "").Replace(" · female", "");
string ColorCss(Color color) => $"rgb({(int)(color.r * 255)},{(int)(color.g * 255)},{(int)(color.b * 255)})";
void SetCharacterName(string value)
{
CharacterName = value;
Feedback = null;
}
void SetPlaythroughName(string value)
{
PlaythroughName = value;
Feedback = null;
}
bool SameColor(Color color) => MathF.Abs(Appearance.HairColorR - color.r) < 0.001f && MathF.Abs(Appearance.HairColorG - color.g) < 0.001f && MathF.Abs(Appearance.HairColorB - color.b) < 0.001f;
void ChangeTab(string tab)
{
if (tab != "Face")
InspectTeeth = false;
if (tab != Tab)
StopVoicePreview();
Tab = tab == "Face & body" ? "Face" : tab;
if (Tab == "Tattoos")
ChooseMakeupLayer("Tattoo");
else if (Tab == "Makeup" && MakeupLayer == "Tattoo")
ChooseMakeupLayer("Lipstick");
if (Tab == "Clothing" || Tab == "Gender")
SetZoom(0);
Feedback = null;
}
void StopVoicePreview()
{
StopPreview?.Invoke();
}
void ChangeShapeCategory(string category)
{
ShapeCategory = category;
Feedback = null;
}
IReadOnlyList<AppearancePreset> portraitSource, portraitPresets;
IReadOnlyList<AppearancePreset> PresetPortraits
{
get
{
var source = AppearanceCatalog.Presets;
if (!ReferenceEquals(portraitSource, source))
{
// New editor drafts use the unified mesh. Preview that same mesh,
// including its eye/lid geometry, without changing catalog or saved data.
portraitPresets = source.Select(preset =>
{
var appearance = preset.Appearance.DeepCopy();
UnifiedHuman.Enable(appearance);
return new AppearancePreset(preset.Id, preset.Label, appearance);
}).ToArray();
portraitSource = source;
}
return portraitPresets;
}
}
void ApplyPreset(string id)
{
if (LoadingCatalog)
return;
var voice = Appearance.VoiceId;
var eyeStyle = Appearance.EyeStyle;
var underwearStyle = Appearance.UnderwearStyle;
bool unified = Appearance.BaseShape is not null;
Appearance = AppearanceCatalog.ApplyPreset(id);
if (unified)
UnifiedHuman.Enable(Appearance);
Appearance.VoiceId = voice;
Appearance.EyeStyle = eyeStyle;
Appearance.UnderwearStyle = underwearStyle;
ChosenPreset = id;
Feedback = null;
}
void EnableBlending()
{
UnifiedHuman.Enable(Appearance);
ChosenPreset = null;
Feedback = null;
}
void AddPhotoFace(PhotoFace face)
{
if (!face.Valid)
return;
UnifiedHuman.Enable(Appearance);
if (BuiltInFaces.CustomCount(Appearance.BaseShape) >= PhotoFaceCapacity.Current)
return;
Array.Fill(Appearance.BaseShape.HeadWeights, 0f);
foreach (var old in Appearance.BaseShape.PhotoFaces)
old.Influence = 0;
Appearance.BaseShape.PhotoFaces.Add(face);
ChosenPreset = null;
Tab = "Face";
BlendRegion = "overall";
SetView(CharacterCreatorPreview.FaceZoom);
Feedback = "Custom base added. Blend it here or refine it in Shape, then save your character.";
}
FaceRegionBlend Region(bool create = false)
{
if (BlendRegion == "overall")
return null;
if (!Appearance.BaseShape.Regions.TryGetValue(BlendRegion, out var value) && create)
{
value = new()
{
OverallInfluence = .5f
};
Appearance.BaseShape.Regions.Add(BlendRegion, value);
}
return value;
}
float OverallWeight => Region()?.OverallInfluence ?? .5f;
void SetOverallWeight(float value)
{
if (!float.IsFinite(value) || BlendRegion == "overall")
return;
Region(true).OverallInfluence = Math.Clamp(value, 0, 1);
ChosenPreset = null;
}
float HeadWeight(int index) => index < 7 ? (BlendRegion == "overall" ? Appearance.BaseShape.HeadWeights[index] : Region()?.HeadWeights[index] ?? 0) : BuiltInFaces.Get(Appearance.BaseShape, index)is { } face ? PhotoWeight(face) : 0;
float PhotoWeight(PhotoFace face) => BlendRegion == "overall" ? face.Influence : Region()?.PhotoWeights.GetValueOrDefault(face.Id) ?? 0;
string BlendWeightLabel(int index)
{
float share = index < 7 ? FaceRegions.NativeShare(Appearance.BaseShape, index, BlendRegion) : BuiltInFaces.Get(Appearance.BaseShape, index)is { } face ? FaceRegions.PhotoEntryShare(Appearance.BaseShape, face, BlendRegion) : 0;
return $"{MathF.Round(HeadWeight(index) * 100)} influence · {MathF.Round(share * 100)}% of blend";
}
void SetHeadWeight(int index, float value)
{
if (Appearance.BaseShape is null || !float.IsFinite(value))
return;
if (index >= 7)
{
SetPhotoWeight(BuiltInFaces.Get(Appearance.BaseShape, index, true), value);
return;
}
(BlendRegion == "overall" ? Appearance.BaseShape.HeadWeights : Region(true).HeadWeights)[index] = Math.Clamp(value, 0, 1);
ChosenPreset = null;
}
void SetPhotoWeight(PhotoFace face, float value)
{
if (!float.IsFinite(value))
return;
if (BlendRegion == "overall")
face.Influence = Math.Clamp(value, 0, 1);
else
Region(true).PhotoWeights[face.Id] = Math.Clamp(value, 0, 1);
ChosenPreset = null;
}
void ResetRegion()
{
if (BlendRegion != "overall")
Appearance.BaseShape.Regions.Remove(BlendRegion);
ChosenPreset = null;
}
void SetBodyBlend(float value)
{
if (Appearance.BaseShape is null || !float.IsFinite(value))
return;
Appearance.BaseShape.MaleBody = Math.Clamp(value, 0, 1);
ChosenPreset = null;
}
BodyRegionBlend BodyRegion(bool create = false)
{
if (Appearance.BaseShape is null || GenderRegion == "overall")
return null;
if (!Appearance.BaseShape.BodyOverrides.TryGetValue(GenderRegion, out var region) && create)
Appearance.BaseShape.BodyOverrides[GenderRegion] = region = new();
return region;
}
void SetBodyRegionBlend(float value)
{
if (LoadingCatalog || !float.IsFinite(value))
return;
var region = BodyRegion(true);
if (region is null)
return;
value = Math.Clamp(value, 0, 1);
region.OverallInfluence = 0;
region.FemaleInfluence = 1 - value;
region.MaleInfluence = value;
ChosenPreset = null;
}
void ResetBodyRegion()
{
if (LoadingCatalog || Appearance.BaseShape is null)
return;
Appearance.BaseShape.BodyOverrides.Remove(GenderRegion);
ChosenPreset = null;
}
void ResetBodyRegions()
{
if (LoadingCatalog || Appearance.BaseShape is null)
return;
Appearance.BaseShape.BodyOverrides.Clear();
ChosenPreset = null;
}
void ToggleClothingVariant(string variant)
{
if (Appearance.BaseShape is null)
return;
Appearance.BaseShape.ClothingVariant = Appearance.BaseShape.ClothingVariant == variant ? null : variant;
ChosenPreset = null;
}
void SetBodyFeature(bool breast, float value)
{
if (Appearance.BaseShape is null || !float.IsFinite(value))
return;
if (breast)
Appearance.BaseShape.BreastSize = Math.Clamp(value, 0, 1);
else
Appearance.BaseShape.BulgeSize = Math.Clamp(value, 0, 1);
ChosenPreset = null;
}
void ChooseBody(string body)
{
var skin = AppearanceCatalog.Skins.FirstOrDefault(x => x.BodyType == body);
if (skin is not null)
ChooseSkin(skin);
}
static string SkinLabel(AppearanceOption skin) => "Skin " + System.IO.Path.GetFileNameWithoutExtension(skin.ResourcePath).Replace("human", "") + (skin.SkinStyle == "original" ? " (Original)" : "");
bool SkinSelected(AppearanceOption skin) => Appearance.SkinPath == skin.ResourcePath && (skin.SkinStyle == Appearance.SkinStyle || (Appearance.SkinStyle == "original" && skin.Id is not ("human03" or "human04") && !skin.Id.EndsWith("-original")));
void ChooseSkin(AppearanceOption skin)
{
if (LoadingCatalog)
return;
Appearance.SkinPath = skin.ResourcePath;
Appearance.SkinStyle = skin.SkinStyle == "original" ? "original" : "stylized";
if (Appearance.BaseShape is null && !AppearanceCatalog.FacialHair.Any(x => x.ResourcePath == Appearance.FacialHairPath && (x.BodyType == "" || x.BodyType == skin.BodyType)))
Appearance.FacialHairPath = "";
ChosenPreset = null;
Feedback = null;
}
void ChooseUnderwear(string style)
{
if (LoadingCatalog)
return;
Appearance.UnderwearStyle = style;
ChosenPreset = null;
Feedback = null;
}
void ChooseEyeStyle(string style)
{
if (LoadingCatalog)
return;
Appearance.EyeStyle = style;
ChosenPreset = null;
Feedback = null;
}
void ChooseHair(AppearanceOption hair)
{
if (LoadingCatalog)
return;
Appearance.HairPath = hair.ResourcePath;
ChosenPreset = null;
Feedback = null;
}
void ChooseFacial(AppearanceOption facial)
{
if (LoadingCatalog)
return;
Appearance.FacialHairPath = facial.ResourcePath;
ChosenPreset = null;
Feedback = null;
}
void SetHairColor(Color color)
{
if (LoadingCatalog || !float.IsFinite(color.r) || !float.IsFinite(color.g) || !float.IsFinite(color.b))
return;
Appearance.HairColor = new Color(Math.Clamp(color.r, 0, 1), Math.Clamp(color.g, 0, 1), Math.Clamp(color.b, 0, 1));
ChosenPreset = null;
Feedback = null;
}
void ChooseColor(AppearanceColorOption color)
{
if (LoadingCatalog)
return;
Appearance.HairColor = color.Color;
ChosenPreset = null;
Feedback = null;
}
void ChooseSkinColor(Color color, bool normalize = false)
{
if (LoadingCatalog)
return;
Appearance.SkinColor = color;
Appearance.NormalizeSkinTone = normalize;
ChosenPreset = null;
Feedback = null;
}
static readonly IReadOnlyList<AppearanceColorOption> TeethColors = new AppearanceColorOption[]
{
new("white", "White", Color.White),
new("ivory", "Ivory", new(.95f, .89f, .75f)),
new("yellowed", "Yellowed", new(.78f, .64f, .33f)),
new("black", "Black", new(.08f, .08f, .08f))
};
bool LowerTeeth;
string SelectedTeethStyle => LowerTeeth ? Appearance.Teeth.EffectiveLowerStyle : Appearance.Teeth.Style;
void ToggleTeethLink()
{
if (LoadingCatalog)
return;
var selected = SelectedTeethStyle;
Appearance.Teeth.Style = Appearance.Teeth.LowerStyle = selected;
Appearance.Teeth.LinkedRows = !Appearance.Teeth.LinkedRows;
CharacterTeethData.Prepare(Appearance);
ChosenPreset = null;
Feedback = null;
}
void ChooseTeethStyle(string style)
{
if (LoadingCatalog)
return;
if (Appearance.Teeth.LinkedRows)
Appearance.Teeth.Style = Appearance.Teeth.LowerStyle = style;
else if (LowerTeeth)
Appearance.Teeth.LowerStyle = style;
else
Appearance.Teeth.Style = style;
CharacterTeethData.Prepare(Appearance);
InspectTeeth = true;
ChosenPreset = null;
Feedback = null;
SetView(CharacterCreatorPreview.FaceZoom);
}
void SetTeethColor(Color color)
{
if (LoadingCatalog)
return;
Appearance.Teeth.Color = color;
ChooseTeethColorMode(true);
}
void ChooseTeethColorMode(bool custom)
{
if (LoadingCatalog)
return;
Appearance.Teeth.CustomColor = custom;
InspectTeeth = true;
ChosenPreset = null;
Feedback = null;
// Keep an inspected angle/detail zoom stable while dragging the picker.
if (Zoom < CharacterCreatorPreview.FaceZoom)
SetZoom(CharacterCreatorPreview.FaceZoom);
}
static IReadOnlyList<AppearanceColorOption> EyeColors => new[]
{
new AppearanceColorOption("brown", "Brown", new(.24f, .12f, .055f)),
new("hazel", "Hazel", new(.34f, .3f, .11f)),
new("amber", "Amber", new(.58f, .34f, .075f)),
new("green", "Green", new(.13f, .34f, .19f)),
new("blue", "Blue", new(.09f, .3f, .55f)),
new("grey", "Grey", new(.32f, .36f, .4f)),
new("violet", "Violet", new(.32f, .16f, .46f))
};
int EyeSide;
bool EditingRightEye => !Appearance.EyesLinked && EyeSide == 1;
bool SelectedEyeCustom => Appearance.UsesCustomEyeColor(EditingRightEye);
Color SelectedEyeColor => Appearance.GetEyeColor(EditingRightEye);
void ToggleEyeLink()
{
if (LoadingCatalog)
return;
var color = SelectedEyeColor;
var custom = SelectedEyeCustom;
Appearance.EyeGlow = Appearance.RightEyeGlow = Appearance.GetEyeGlow(EditingRightEye);
Appearance.EyeColor = Appearance.RightEyeColor = color;
Appearance.CustomEyeColor = Appearance.CustomRightEyeColor = custom;
Appearance.EyesLinked = !Appearance.EyesLinked;
EyeSide = 0;
ChosenPreset = null;
Feedback = null;
}
void CopyEyeAcross()
{
if (LoadingCatalog)
return;
Appearance.EyeGlow = Appearance.RightEyeGlow = Appearance.GetEyeGlow(EditingRightEye);
Appearance.EyeColor = Appearance.RightEyeColor = SelectedEyeColor;
Appearance.CustomEyeColor = Appearance.CustomRightEyeColor = SelectedEyeCustom;
ChosenPreset = null;
Feedback = null;
}
void ChooseEyeColor(Color? color)
{
if (LoadingCatalog)
return;
if (EditingRightEye)
{
Appearance.CustomRightEyeColor = color.HasValue;
if (color.HasValue)
Appearance.RightEyeColor = color.Value;
}
else
{
Appearance.CustomEyeColor = color.HasValue;
if (color.HasValue)
Appearance.EyeColor = color.Value;
}
if (Appearance.EyesLinked)
{
Appearance.RightEyeColor = Appearance.EyeColor;
Appearance.CustomRightEyeColor = Appearance.CustomEyeColor;
}
ChosenPreset = null;
Feedback = null;
}
void SetEyeGlow(float value)
{
if (LoadingCatalog || !float.IsFinite(value))
return;
value = Math.Clamp(value, 0, 1);
if (EditingRightEye)
Appearance.RightEyeGlow = value;
else
Appearance.EyeGlow = value;
if (Appearance.EyesLinked)
Appearance.RightEyeGlow = value;
ChosenPreset = null;
Feedback = null;
}
float EyeChannel(string channel) => channel == "Red" ? SelectedEyeColor.r : channel == "Green" ? SelectedEyeColor.g : SelectedEyeColor.b;
void SetEyeChannel(string channel, float value)
{
if (!float.IsFinite(value))
return;
var color = SelectedEyeColor;
value = Math.Clamp(value, 0, 1);
if (channel == "Red")
color.r = value;
else if (channel == "Green")
color.g = value;
else
color.b = value;
ChooseEyeColor(color);
}
float SkinChannel(string channel) => channel == "Red" ? Appearance.SkinColorR : channel == "Green" ? Appearance.SkinColorG : Appearance.SkinColorB;
void SetSkinChannel(string channel, float value)
{
if (LoadingCatalog || !float.IsFinite(value))
return;
value = Math.Clamp(value, 0, 1);
Appearance.NormalizeSkinTone = true;
if (channel == "Red")
Appearance.SkinColorR = value;
else if (channel == "Green")
Appearance.SkinColorG = value;
else
Appearance.SkinColorB = value;
ChosenPreset = null;
Feedback = null;
}
void SetShape(AppearanceSlider slider, float value)
{
if (LoadingCatalog)
return;
slider.Set(Appearance, value);
ChosenPreset = null;
Feedback = null;
}
static string MaturityLabel(float value) => value <= 0 ? "Child" : value >= 1 ? "Adult" : MathF.Abs(value - .5f) < .005f ? "Adolescent" : $"{MathF.Round(value * 100)}%";
string EarShapeLabel()
{
var weights = EarShapes.Weights(Appearance);
var parts = new[]
{
(Name: "Goblin", Weight: weights.x),
(Name: "Elf", Weight: weights.y),
(Name: "Animal", Weight: weights.z)
}.Where(x => x.Weight >= .005f).ToArray();
if (parts.Length == 0)
return "Human";
if (parts.Length == 1)
return parts[0].Weight >= .995f ? parts[0].Name : $"{MathF.Round(parts[0].Weight * 100)}% {parts[0].Name}";
return string.Join(" / ", parts.Select(x => x.Name)) + " blend";
}
void SetEarShape(Vector2 value)
{
if (LoadingCatalog)
return;
EarShapes.SetPosition(Appearance, value);
AnimalEarShape.Prepare(Appearance);
ChosenPreset = null;
Feedback = null;
}
void ResetEarShape()
{
if (LoadingCatalog)
return;
EarShapes.Reset(Appearance);
ChosenPreset = null;
Feedback = null;
}
void SetHeight(float value)
{
if (LoadingCatalog || !float.IsFinite(value))
return;
Appearance.Height = Math.Clamp(value, 0, 1);
if (Zoom > 0)
SetView(0);
ChosenPreset = null;
Feedback = null;
}
void SetBodyMaturity(float value)
{
if (LoadingCatalog || !float.IsFinite(value))
return;
value = Math.Clamp(value, 0, 1);
if (value < 1)
UnifiedHuman.Enable(Appearance);
Appearance.BodyMaturity = value;
if (Appearance.MaturityLinked)
Appearance.FaceMaturity = value;
ChosenPreset = null;
Feedback = null;
}
void SetFaceMaturity(float value)
{
if (LoadingCatalog || !float.IsFinite(value))
return;
value = Math.Clamp(value, 0, 1);
if (value < 1)
UnifiedHuman.Enable(Appearance);
Appearance.FaceMaturity = value;
ChosenPreset = null;
Feedback = null;
}
void ToggleMaturityLink()
{
if (LoadingCatalog)
return;
// Detaching starts from the face the user is currently seeing. Linking
// makes the body slider the shared value without a hidden second offset.
Appearance.FaceMaturity = Appearance.BodyMaturity;
Appearance.MaturityLinked = !Appearance.MaturityLinked;
ChosenPreset = null;
Feedback = null;
}
void HornsChanged()
{
ChosenPreset = null;
Feedback = null;
}
static string TailTipLabel(string tip) => tip switch
{
"arrow" => "Arrowhead",
"spade" => "Spade",
"tapered" => "Smooth taper",
"single" => "Single barb",
"tuft" => "Fur tuft",
"spikes" => "Stego spikes",
_ => "Twin barb"
};
void ToggleTail()
{
if (LoadingCatalog)
return;
Appearance.Tail.Enabled = !Appearance.Tail.Enabled;
ChosenPreset = null;
Feedback = null;
}
void ChooseTailTip(string tip)
{
if (LoadingCatalog || !CharacterTailData.IsTip(tip))
return;
Appearance.Tail.Tip = tip;
ChosenPreset = null;
Feedback = null;
}
void SetTailLength(float value)
{
if (LoadingCatalog || !float.IsFinite(value))
return;
Appearance.Tail.Length = Math.Clamp(value, 0, 1);
ChosenPreset = null;
Feedback = null;
}
void SetTailThickness(float value)
{
if (LoadingCatalog || !float.IsFinite(value))
return;
Appearance.Tail.Thickness = Math.Clamp(value, 0, 1);
ChosenPreset = null;
Feedback = null;
}
void SetTailTipSize(float value)
{
if (LoadingCatalog || !float.IsFinite(value))
return;
Appearance.Tail.TipSize = Math.Clamp(value, 0, 1);
ChosenPreset = null;
Feedback = null;
}
void SetTailTipColor(Color value)
{
if (LoadingCatalog || !float.IsFinite(value.r) || !float.IsFinite(value.g) || !float.IsFinite(value.b))
return;
Appearance.Tail.TipColor = new Color(Math.Clamp(value.r, 0, 1), Math.Clamp(value.g, 0, 1), Math.Clamp(value.b, 0, 1));
Appearance.Tail.CustomTipColor = true;
ChosenPreset = null;
Feedback = null;
}
void ResetTailTipColor()
{
if (LoadingCatalog)
return;
Appearance.Tail.CustomTipColor = false;
ChosenPreset = null;
Feedback = null;
}
void ResetShapes()
{
if (LoadingCatalog)
return;
AppearanceCatalog.ResetShapes(Appearance);
ChosenPreset = null;
Feedback = null;
}
void Randomize()
{
if (LoadingCatalog)
return;
var voice = Appearance.VoiceId;
var variant = Appearance.BaseShape?.ClothingVariant;
var eyeStyle = Appearance.EyeStyle;
var underwearStyle = Appearance.UnderwearStyle;
var photos = Appearance.BaseShape?.PhotoFaces.Select(x => x.DeepCopy()).ToList();
Appearance = AppearanceCatalog.Randomize(blendHeads: Appearance.BaseShape is not null);
Appearance.VoiceId = voice;
Appearance.EyeStyle = eyeStyle;
Appearance.UnderwearStyle = underwearStyle;
if (Appearance.BaseShape is not null)
{
Appearance.BaseShape.ClothingVariant = variant;
if (photos is not null)
{
foreach (var face in photos)
face.Influence = 0;
Appearance.BaseShape.PhotoFaces = photos;
}
}
ChosenPreset = null;
Feedback = null;
}
void Reset()
{
if (LoadingCatalog)
return;
var voice = Appearance.VoiceId;
bool unified = Appearance.BaseShape is not null;
var photos = Appearance.BaseShape?.PhotoFaces.Select(x => x.DeepCopy()).ToList();
Appearance = AppearanceCatalog.CreateDefault();
if (unified)
{
UnifiedHuman.Enable(Appearance);
// Reset the character, but retain imported bases as available choices.
// Their explicit delete buttons remove them from the draft.
foreach (var face in photos)
face.Influence = 0;
Appearance.BaseShape.PhotoFaces = photos;
}
Appearance.VoiceId = voice;
ChosenPreset = "traveller";
Yaw = 0;
RecenterPreview();
SetZoom(CharacterCreatorPreview.FaceZoom);
Feedback = null;
}
void Rotate(float delta)
{
Yaw = (Yaw + delta) % 360;
}
void SetYaw(float value)
{
Yaw = value;
}
void SetView(float value)
{
RecenterPreview();
SetZoom(value);
}
void SetZoom(float value)
{
if (float.IsFinite(value))
{
Zoom = Math.Clamp(value, 0, CharacterCreatorPreview.MaxZoom);
Portrait = Zoom >= CharacterCreatorPreview.FaceZoom - 0.001f;
}
}
void SetPitch(float value)
{
PreviewPitch = value;
}
void SetPan(Vector3 value)
{
PreviewPan = value;
}
void RecenterPreview()
{
Yaw = 0;
PreviewPan = Vector3.Zero;
PreviewPitch = 0;
Preview?.Recenter();
}
void ToggleClothing()
{
ShowClothing = !ShowClothing;
}
void Back()
{
if (!LoadingCatalog)
{
StopVoicePreview();
Closed?.Invoke();
}
}
void Start()
{
if (Disabled || LoadingCatalog || Preview?.Loading == true)
return;
if (!CatalogReady)
{
Feedback = LoadingCatalog ? "Loading character options…" : "Character options could not be loaded.";
return;
}
if (CharacterAppearance.ValidateForApply(Appearance)is string error)
{
Feedback = error;
return;
}
StopVoicePreview();
Feedback = Saved?.Invoke(CharacterName, Appearance.DeepCopy());
}
public override void Delete(bool immediate = false)
{
StopVoicePreview();
base.Delete(immediate);
}
protected override async Task OnParametersSetAsync()
{
if (InitialPreset is not null && !ReferenceEquals(loadedPreset, InitialPreset))
{
LoadDraftOnTab(InitialPreset.Name, InitialPreset.Appearance, InitialTab);
loadedPreset = InitialPreset;
}
if (CatalogReady || LoadingCatalog)
return;
LoadingCatalog = true;
try
{
await AppearanceCatalog.EnsureLoadedAsync();
CatalogReady = true;
}
catch (Exception)
{
Feedback = "Character options could not be loaded. Please try again.";
}
finally
{
LoadingCatalog = false;
}
}
protected override int BuildHash() => HashCode.Combine(CharacterName, PlaythroughName, HashCode.Combine(Tab, ShapeCategory, SkinPalette, MakeupLayer, CustomMakeup, SelectedMarking, MarkingSection, HashCode.Combine(BlendRegion, GenderRegion, EyeSide, LowerTeeth)), ChosenPreset, JsonSerializer.Serialize(Appearance) + Appearance.Makeup.PresentationKey(), HashCode.Combine(Portrait, Yaw, Feedback, LoadingCatalog, SaveLabel, Preview?.Error, CatalogReady, HashCode.Combine(LoadingCatalog, Disabled, ProductLabel, LocationLabel, Title)), Zoom, HashCode.Combine(ShowClothing, PreviewPan, PreviewPitch, WardrobeSearch, WardrobeCategory, ClothingResources.Revision, HashCode.Combine(CloudWardrobeOpen, CustomHairColor, DynamicLook, InspectTeeth)));
}