Code/CharacterKit/CharacterAvatar.cs

Component that represents a drop-in human avatar used by the editor and the world. It stores a JSON preset, applies and validates CharacterPreset data, synchronizes a Dresser wardrobe, manages a CharacterVisual for rendering, and exposes editor buttons for randomize, load, refresh, and edit behavior.

File AccessNetworking
using System;
using System.Threading.Tasks;

namespace CharacterEditor;
/// <summary>Drop-in human prefab. The same portable JSON drives editor and world rendering.</summary>
public sealed class CharacterAvatar : Component, Component.ExecuteInEditor
{
	[Property, TextArea]
	public string PresetJson { get; set; } = "";

	[Property]
	public bool ShowClothing { get; set; } = true;

	[Property]
	public bool AllowEditing { get; set; } = true;

	[Property]
	public bool ShowEditButton { get; set; }

	[Property]
	public SkinnedModelRenderer BodyTarget { get; set; }

	[Property]
	public Dresser Wardrobe { get; set; }
	public CharacterPreset Preset { get; private set; } = new();

	[Property, ReadOnly]
	public string Error { get; private set; }
	public SkinnedModelRenderer Body => BodyTarget.IsValid() ? BodyTarget : visual?.Body;

	CharacterVisual visual;
	string received;
	GameObject editPrompt;
	Task wardrobeLoad;
	int wardrobeRevision;
	public void Apply(CharacterPreset preset)
	{
		if (preset.Validate()is string error)
			throw new InvalidOperationException(error);
		Preset = preset.DeepCopy();
		if (BodyTarget.IsValid() && Preset.Appearance.BaseShape is null)
			UnifiedHuman.Enable(Preset.Appearance);
		PresetJson = Preset.ToJson();
		received = PresetJson;
		SyncWardrobe();
	}

	[Button]
	public void Randomize()
	{
		var previous = Preset.Appearance;
		var a = AppearanceCatalog.Randomize(blendHeads: true);
		a.VoiceId = previous.VoiceId;
		if (previous.BaseShape is { } shape)
		{
			a.BaseShape.ClothingVariant = shape.ClothingVariant;
			a.BaseShape.PhotoFaces = shape.PhotoFaces.Select(f => f.DeepCopy()).ToList();
			foreach (var face in a.BaseShape.PhotoFaces)
				face.Influence = 0;
		}

		var name = GameObject.Name;
		Apply(new CharacterPreset { Name = string.IsNullOrWhiteSpace(name) ? "New character" : name[..Math.Min(name.Length, 40)], Appearance = a });
	}

	[Button]
	public void LoadJson()
	{
		try
		{
			Apply(CharacterPreset.FromJson(PresetJson));
			Error = null;
		}
		catch (Exception e)
		{
			Error = e.Message;
		}
	}

	[Button]
	public void RefreshAppearance()
	{
		visual?.Invalidate();
		OnUpdate();
	}

	[Button]
	public void UpdateJson()
	{
		if (Wardrobe.IsValid())
		{
			var items = Wardrobe.Clothing.Where(e => e.Clothing is not null).Select(e => e.Clothing).ToArray();
			var a = Preset.Appearance;
			a.HairPath = items.FirstOrDefault(ClothingResources.IsHair) is { } hair ? ClothingResources.Reference(hair) : "";
			a.FacialHairPath = items.FirstOrDefault(ClothingResources.IsFacialHair) is { } facial ? ClothingResources.Reference(facial) : "";
			a.ClothingPaths = items.Where(c => !ClothingResources.IsBaseSkin(c) && !ClothingResources.IsHair(c) && !ClothingResources.IsFacialHair(c)).Select(ClothingResources.Reference).ToArray();
		}

		if (Preset.Validate()is string error)
		{
			Error = error;
			return;
		}

		PresetJson = Preset.ToJson();
		received = PresetJson;
		Error = null;
	}

	void SyncWardrobe()
	{
		if (!Wardrobe.IsValid())
			return;
		wardrobeLoad = SyncWardrobeAsync(Preset.Appearance.DeepCopy(), ++wardrobeRevision);
	}

	async Task SyncWardrobeAsync(CharacterAppearanceData a, int revision)
	{
		await CharacterVisual.Load(a);
		if (!IsValid || revision != wardrobeRevision || !Wardrobe.IsValid())
			return;
		var container = new ClothingContainer
		{
			PrefersHuman = true
		};
		var paths = new[]
		{
			UnifiedHuman.PresentationSkin(a),
			a.HairPath,
			a.FacialHairPath
		}.Concat(WardrobeCatalog.Paths(a)).Where(p => !string.IsNullOrEmpty(p));
		foreach (var path in paths)
		{
			var item = ClothingResources.Get(path);
			if (item is not null)
				container.Add(item);
		}

		Wardrobe.Source = Dresser.ClothingSource.Manual;
		Wardrobe.BodyTarget = Body;
		Wardrobe.ApplyHeightScale = false;
		Wardrobe.Clothing = container.Clothing;
		Wardrobe.Apply(container);
	}

	protected override void OnUpdate()
	{
		if (received != PresetJson)
		{
			try
			{
				bool initial = received is null;
				Preset = string.IsNullOrWhiteSpace(PresetJson) ? new CharacterPreset() : CharacterPreset.FromJson(PresetJson);
				if (BodyTarget.IsValid() && Preset.Appearance.BaseShape is null)
					UnifiedHuman.Enable(Preset.Appearance);
				received = PresetJson;
				if (!string.IsNullOrWhiteSpace(PresetJson) && (!initial || !Wardrobe.IsValid() || Wardrobe.Clothing.Count == 0))
					SyncWardrobe();
				Error = null;
			}
			catch (Exception e)
			{
				Error = e.Message;
				return;
			}
		}

		if (wardrobeLoad?.IsFaulted == true)
		{
			Error = wardrobeLoad.Exception.GetBaseException().Message;
			return;
		}

		if (wardrobeLoad?.IsCompleted == false)
			return;
		visual ??= new CharacterVisual(GameObject, BodyTarget, Wardrobe);
		visual.Update(Preset.Appearance, ShowClothing);
		Error = visual.Error;
		if (!Scene.IsEditor && ShowEditButton && AllowEditing && !editPrompt.IsValid())
		{
			editPrompt = new GameObject
			{
				Parent = GameObject,
				Name = "Edit character button",
				NetworkMode = NetworkMode.Never
			};
			editPrompt.Components.Create<ScreenPanel>();
			editPrompt.Components.Create<AvatarEditButton>().Avatar = this;
		}

		if (editPrompt.IsValid())
			editPrompt.Enabled = !Scene.IsEditor && ShowEditButton && AllowEditing;
	}

	public void OpenEditor()
	{
		if (!AllowEditing)
			return;
		var session = Scene.GetAllComponents<StudioSession>().FirstOrDefault();
		if (!session.IsValid())
		{
			using var scope = Scene.Push();
			var ui = new GameObject
			{
				Name = "Character editor",
				NetworkMode = NetworkMode.Never
			};
			session = ui.Components.Create<StudioSession>();
			session.Embedded = true;
			ui.Components.Create<ScreenPanel>();
			ui.Components.Create<StudioPanel>();
		}

		session.Edit(Preset, this);
	}

	protected override void OnDisabled()
	{
		visual?.Dispose();
		visual = null;
	}

	protected override void OnDestroy()
	{
		visual?.Dispose();
	}
}