Wardrobe catalog for character appearance, defines WardrobeItem record, default paths and categories, conflict checking, validation, toggling clothing on a character, random outfit selection, and loading clothing models and resources asynchronously.
using System;
using System.Threading.Tasks;
namespace CharacterEditor;
public sealed record WardrobeItem(string Name, string Category, string Slot, string Path, string SlotsUnder = "", string SlotsOver = "", string HideBody = "", string NativeCategory = "");
public static partial class WardrobeCatalog
{
public static readonly string[] DefaultPaths =
{
"models/citizen_clothes/shirt/army_shirt/army_shirt.clothing",
"models/citizen_clothes/trousers/cargopants/brown_cargo_pants.clothing",
"models/citizen_clothes/shoes/boots/army_boots.clothing"
};
public static readonly string[] Categories =
{
"Tops",
"Bottoms",
"Shoes",
"Outerwear",
"Outfits",
"Glasses",
"Hats",
"Gloves",
"Accessories",
"Hair",
"Facial hair"
};
static bool Overlap(string a, string b) => (a ?? "").Split(',').Select(x => x.Trim()).Where(x => x.Length > 0).Intersect((b ?? "").Split(',').Select(x => x.Trim())).Any();
public static bool Conflicts(WardrobeItem a, WardrobeItem b)
{
var first = ClothingResources.Get(a.Path);
var second = ClothingResources.Get(b.Path);
if (first is not null && second is not null)
return !first.CanBeWornWith(second);
return a.Slot == b.Slot || Overlap(a.SlotsUnder, b.SlotsUnder) || Overlap(a.SlotsOver, b.SlotsOver);
}
public static IEnumerable<string> Paths(CharacterAppearanceData a) => (a.ClothingPaths ?? DefaultPaths).Select(p => p?.ToLowerInvariant());
public static string Validate(string[] paths)
{
if (paths is null)
return null;
if (paths.Length > 16 || paths.Distinct(StringComparer.OrdinalIgnoreCase).Count() != paths.Length)
return "Choose up to 16 different clothing items.";
var available = ClothingResources.Available.ToArray();
var items = paths.Select(p => available.FirstOrDefault(x => string.Equals(x.Path, p, StringComparison.OrdinalIgnoreCase))).ToArray();
// Native Dresser users may add their own human clothing resources. Keep
// portable paths while the engine handles those items' native metadata.
if (paths.Any(p => !ClothingResources.ValidReference(p)))
return "Use clothing resource paths or cloud:organization.item identities.";
var known = items.Where(i => i is not null).ToArray();
for (var i = 0; i < known.Length; i++)
for (var j = i + 1; j < known.Length; j++)
if (Conflicts(known[i], known[j]))
return "These clothing items occupy the same clothing slots.";
return null;
}
public static void Toggle(CharacterAppearanceData a, WardrobeItem item)
{
var selected = Paths(a).ToList();
var clothing = ClothingResources.Get(item.Path);
var worn = selected.FindIndex(path => string.Equals(path, item.Path, StringComparison.OrdinalIgnoreCase) || (clothing is not null && ClothingResources.Get(path) == clothing));
if (worn >= 0)
selected.RemoveAt(worn);
else
{
selected.RemoveAll(path =>
{
var other = ClothingResources.Available.FirstOrDefault(x => x.Path == path);
return other is not null && Conflicts(item, other);
});
selected.Add(item.Path);
}
a.ClothingPaths = selected.ToArray();
a.Version = 2;
}
// Deliberate everyday outfit preferences, separate from unrestricted manual choices.
public static string[] Randomize(Random rng, bool female)
{
var chosen = new List<WardrobeItem>();
bool Everyday(WardrobeItem i) => !i.Path.Contains("/seasonal/") && !i.Path.Contains("boxing") && !i.Path.Contains("football") && !i.Path.Contains("chef") && !i.Path.Contains("fisherman") && !i.Path.Contains("prison") && !i.Path.Contains("leotard");
void Pick(string category, Func<WardrobeItem, bool> allow)
{
var pool = Items.Where(i => i.Category == category && Everyday(i) && allow(i) && !chosen.Any(c => Conflicts(c, i))).ToArray();
if (pool.Length > 0)
chosen.Add(pool[rng.Next(pool.Length)]);
}
if (female && rng.NextDouble() < .25)
Pick("Outfits", i => i.NativeCategory == "Dress");
if (chosen.Count == 0)
{
Pick("Tops", i => female || !i.Path.Contains("crop_top"));
Pick("Bottoms", i => female || i.NativeCategory != "Skirt");
}
Pick("Shoes", i => female || i.NativeCategory != "Heels");
return chosen.Select(i => i.Path).ToArray();
}
public static async Task Load(CharacterAppearanceData a)
{
foreach (var path in Paths(a))
{
var item = await ClothingResources.Load(path);
if (item is null)
throw new InvalidOperationException("Clothing unavailable: " + path);
}
if (a.BaseShape is not null)
{
var skin = ClothingResources.Get(UnifiedHuman.PresentationSkin(a));
var tags = new TagSet(skin.HumanSkinTags.TryGetAll());
tags.Add("human");
var selected = new[]
{
skin
}.Concat(Paths(a).Select(ClothingResources.Get)).ToArray();
foreach (var item in selected.Skip(1))
{
await Model.LoadAsync(UnifiedWardrobe.ModelPath(item, selected, tags, a));
if ((item.HideBody & Clothing.BodyGroups.Head) != 0 && !string.IsNullOrEmpty(item.HumanSkinModel))
await Model.LoadAsync(item.HumanSkinModel);
}
}
}
}