CharacterKit/WardrobeCatalog.cs

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.

File AccessNetworking
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);
			}
		}
	}
}