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.
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();
}
}