diff --git a/Content.Client/Content.Client.csproj b/Content.Client/Content.Client.csproj
index 43fd8df2e1..62ab76ee21 100644
--- a/Content.Client/Content.Client.csproj
+++ b/Content.Client/Content.Client.csproj
@@ -23,6 +23,11 @@
+
+
+ WizardMirrorWindow.xaml
+
+
diff --git a/Content.Client/_White/Wizard/Mirror/WizardMirrorBoundUserInterface.cs b/Content.Client/_White/Wizard/Mirror/WizardMirrorBoundUserInterface.cs
new file mode 100644
index 0000000000..e7cdf5356a
--- /dev/null
+++ b/Content.Client/_White/Wizard/Mirror/WizardMirrorBoundUserInterface.cs
@@ -0,0 +1,53 @@
+using Content.Shared._White.Wizard.Mirror;
+using Content.Shared.Preferences;
+using Robust.Shared.Prototypes;
+
+namespace Content.Client._White.Wizard.Mirror;
+
+public sealed class WizardMirrorBoundUserInterface(EntityUid owner, Enum uiKey) : BoundUserInterface(owner, uiKey)
+{
+ [Dependency] private readonly IPrototypeManager _prototypeManager = default!;
+
+ [ViewVariables]
+ private WizardMirrorWindow? _window;
+
+ protected override void Open()
+ {
+ base.Open();
+
+ _window = new(_prototypeManager);
+
+ _window.OnSave += Save;
+
+ _window.OnClose += Close;
+ _window.OpenCentered();
+ }
+
+ private void Save(HumanoidCharacterProfile profile)
+ {
+ SendMessage(new WizardMirrorSave(profile));
+ }
+
+ protected override void UpdateState(BoundUserInterfaceState state)
+ {
+ base.UpdateState(state);
+
+ if (state is not WizardMirrorUiState data || _window == null)
+ return;
+
+ _window.UpdateState(data);
+ }
+
+ protected override void Dispose(bool disposing)
+ {
+ base.Dispose(disposing);
+ if (!disposing)
+ return;
+
+ if (_window != null)
+ _window.OnClose -= Close;
+
+ _window?.Dispose();
+ }
+}
+
diff --git a/Content.Client/_White/Wizard/Mirror/WizardMirrorWindow.cs b/Content.Client/_White/Wizard/Mirror/WizardMirrorWindow.cs
new file mode 100644
index 0000000000..5053205b2b
--- /dev/null
+++ b/Content.Client/_White/Wizard/Mirror/WizardMirrorWindow.cs
@@ -0,0 +1,692 @@
+using System.Linq;
+using Content.Client._White.Sponsors;
+using Content.Client.Humanoid;
+using Content.Shared._White.Wizard.Mirror;
+using Content.Shared.Humanoid;
+using Content.Shared.Humanoid.Markings;
+using Content.Shared.Preferences;
+using Robust.Client.AutoGenerated;
+using Robust.Client.UserInterface.Controls;
+using Robust.Client.UserInterface.CustomControls;
+using Robust.Client.UserInterface.XAML;
+using Content.Shared._White.TTS;
+using Content.Shared.Humanoid.Prototypes;
+using Robust.Shared.Enums;
+using Robust.Shared.Prototypes;
+
+namespace Content.Client._White.Wizard.Mirror;
+
+[GenerateTypedNameReferences]
+public sealed partial class WizardMirrorWindow : DefaultWindow
+{
+ private LineEdit NameEdit => CNameEdit;
+
+ private Button SaveButton => CSaveButton;
+ public Action? OnSave;
+
+ private OptionButton SexButton => CSexButton;
+
+ private LineEdit AgeEdit => CAgeEdit;
+
+ private OptionButton GenderButton => CPronounsButton;
+
+ private OptionButton VoiceButton => CVoiceButton;
+
+ private OptionButton BodyTypesButton => CBodyTypesButton;
+
+ private OptionButton SpeciesButton => CSpeciesButton;
+
+ private Slider SkinColorSlider => CSkin;
+ private BoxContainer RgbSkinColorContainer => CRgbSkinColorContainer;
+ private ColorSelectorSliders _rgbSkinColorSelector;
+
+ // Hair
+ private SingleMarkingPicker HairPicker => CHairStylePicker;
+ private SingleMarkingPicker FacialHairPicker => CFacialHairPicker;
+
+ private EyeColorPicker EyesPicker => CEyeColorPicker;
+
+ private bool _isDirty;
+ public HumanoidCharacterProfile? Profile;
+
+ private readonly MarkingManager _markingManager;
+ private readonly IPrototypeManager _prototypeManager;
+
+ private List _bodyTypesList = new();
+
+ private readonly List _speciesList;
+
+ private List _voiceList = default!;
+ private const string AnySexVoiceProto = "SponsorAnySexVoices";
+
+ public WizardMirrorWindow(IPrototypeManager prototypeManager)
+ {
+ RobustXamlLoader.Load(this);
+
+ _markingManager = IoCManager.Resolve();
+ _prototypeManager = prototypeManager;
+
+ Profile ??= HumanoidCharacterProfile.RandomWithSpecies();
+
+ SaveButton.OnPressed += _ => OnSave!(Profile);
+
+ _voiceList = _prototypeManager.EnumeratePrototypes().Where(o => o.RoundStart).ToList();
+
+ #region Voice
+
+ VoiceButton.OnItemSelected += args =>
+ {
+ VoiceButton.SelectId(args.Id);
+ SetVoice(_voiceList[args.Id].ID);
+ };
+
+ #endregion
+
+ #region Name
+
+ NameEdit.OnTextChanged += args => { SetName(args.Text); };
+
+ #endregion
+
+ #region Age
+
+ AgeEdit.OnTextChanged += args =>
+ {
+ if (!int.TryParse(args.Text, out var newAge))
+ return;
+ SetAge(newAge);
+ };
+
+ #endregion Age
+
+ #region Sex
+
+ SexButton.OnItemSelected += args =>
+ {
+ SexButton.SelectId(args.Id);
+ SetSex((Sex) args.Id);
+ };
+
+ #endregion Sex
+
+ #region Gender
+
+ GenderButton.AddItem(Loc.GetString("humanoid-profile-editor-pronouns-male-text"), (int) Gender.Male);
+ GenderButton.AddItem(Loc.GetString("humanoid-profile-editor-pronouns-female-text"), (int) Gender.Female);
+ GenderButton.AddItem(Loc.GetString("humanoid-profile-editor-pronouns-epicene-text"), (int) Gender.Epicene);
+ GenderButton.AddItem(Loc.GetString("humanoid-profile-editor-pronouns-neuter-text"), (int) Gender.Neuter);
+
+ GenderButton.OnItemSelected += args =>
+ {
+ GenderButton.SelectId(args.Id);
+ SetGender((Gender) args.Id);
+ };
+
+ #endregion Gender
+
+ #region Body Type
+
+ BodyTypesButton.OnItemSelected += OnBodyTypeSelected;
+
+ UpdateBodyTypes();
+
+ #endregion Body Type
+
+ #region Skin
+
+
+ SkinColorSlider.OnValueChanged += _ =>
+ {
+ OnSkinColorOnValueChanged();
+ };
+
+ RgbSkinColorContainer.AddChild(_rgbSkinColorSelector = new ColorSelectorSliders());
+ _rgbSkinColorSelector.OnColorChanged += _ =>
+ {
+ OnSkinColorOnValueChanged();
+ };
+
+ #endregion
+
+ #region Species
+
+ _speciesList = prototypeManager.EnumeratePrototypes().Where(o => o.RoundStart).ToList();
+
+ for (var i = 0; i < _speciesList.Count; i++)
+ {
+ var specie = _speciesList[i];
+ var name = Loc.GetString(specie.Name);
+
+ SpeciesButton.AddItem(name, i);
+ }
+
+ SpeciesButton.OnItemSelected += args =>
+ {
+ SpeciesButton.SelectId(args.Id);
+ SetSpecies(_speciesList[args.Id].ID);
+ UpdateHairPickers();
+ OnSkinColorOnValueChanged();
+ };
+
+ #endregion Species
+
+ #region Hair
+
+ HairPicker.OnMarkingSelect += newStyle =>
+ {
+ if (Profile is null)
+ return;
+
+ Profile = Profile.WithCharacterAppearance(
+ Profile.Appearance.WithHairStyleName(newStyle.id));
+ IsDirty = true;
+ };
+
+ HairPicker.OnColorChanged += newColor =>
+ {
+ if (Profile is null)
+ return;
+ Profile = Profile.WithCharacterAppearance(
+ Profile.Appearance.WithHairColor(newColor.marking.MarkingColors[0]));
+ UpdateCMarkingsHair();
+ IsDirty = true;
+ };
+
+ FacialHairPicker.OnMarkingSelect += newStyle =>
+ {
+ if (Profile is null)
+ return;
+ Profile = Profile.WithCharacterAppearance(
+ Profile.Appearance.WithFacialHairStyleName(newStyle.id));
+ IsDirty = true;
+ };
+
+ FacialHairPicker.OnColorChanged += newColor =>
+ {
+ if (Profile is null)
+ return;
+ Profile = Profile.WithCharacterAppearance(
+ Profile.Appearance.WithFacialHairColor(newColor.marking.MarkingColors[0]));
+ UpdateCMarkingsFacialHair();
+ IsDirty = true;
+ };
+
+ HairPicker.OnSlotRemove += _ =>
+ {
+ if (Profile is null)
+ return;
+ Profile = Profile.WithCharacterAppearance(
+ Profile.Appearance.WithHairStyleName(HairStyles.DefaultHairStyle)
+ );
+ UpdateHairPickers();
+ UpdateCMarkingsHair();
+ IsDirty = true;
+ };
+
+ FacialHairPicker.OnSlotRemove += _ =>
+ {
+ if (Profile is null)
+ return;
+ Profile = Profile.WithCharacterAppearance(
+ Profile.Appearance.WithFacialHairStyleName(HairStyles.DefaultFacialHairStyle)
+ );
+ UpdateHairPickers();
+ UpdateCMarkingsFacialHair();
+ IsDirty = true;
+ };
+
+ HairPicker.OnSlotAdd += delegate
+ {
+ if (Profile is null)
+ return;
+
+ var hair = _markingManager.MarkingsByCategoryAndSpecies(MarkingCategories.Hair, Profile.Species)
+ .Keys
+ .FirstOrDefault();
+
+ if (string.IsNullOrEmpty(hair))
+ return;
+
+ Profile = Profile.WithCharacterAppearance(
+ Profile.Appearance.WithHairStyleName(hair)
+ );
+
+ UpdateHairPickers();
+ UpdateCMarkingsHair();
+ IsDirty = true;
+ };
+
+ FacialHairPicker.OnSlotAdd += delegate
+ {
+ if (Profile is null)
+ return;
+
+ var hair = _markingManager.MarkingsByCategoryAndSpecies(MarkingCategories.FacialHair, Profile.Species)
+ .Keys
+ .FirstOrDefault();
+
+ if (string.IsNullOrEmpty(hair))
+ return;
+
+ Profile = Profile.WithCharacterAppearance(
+ Profile.Appearance.WithFacialHairStyleName(hair)
+ );
+
+ UpdateHairPickers();
+ UpdateCMarkingsFacialHair();
+ IsDirty = true;
+ };
+
+ #endregion Hair
+
+ #region Eyes
+
+ EyesPicker.OnEyeColorPicked += newColor =>
+ {
+ if (Profile is null)
+ return;
+ Profile = Profile.WithCharacterAppearance(
+ Profile.Appearance.WithEyeColor(newColor));
+ IsDirty = true;
+ };
+
+ #endregion Eyes
+ }
+
+ #region Set
+
+ private void SetAge(int newAge)
+ {
+ Profile = Profile?.WithAge(newAge);
+ IsDirty = true;
+ }
+
+ private void SetSex(Sex newSex)
+ {
+ Profile = Profile?.WithSex(newSex);
+ switch (newSex)
+ {
+ case Sex.Male:
+ Profile = Profile?.WithGender(Gender.Male);
+ break;
+ case Sex.Female:
+ Profile = Profile?.WithGender(Gender.Female);
+ break;
+ default:
+ Profile = Profile?.WithGender(Gender.Epicene);
+ break;
+ }
+ UpdateGenderControls();
+ UpdateTtsVoicesControls();
+ IsDirty = true;
+ }
+
+ private void SetVoice(string newVoice)
+ {
+ Profile = Profile?.WithVoice(newVoice);
+ IsDirty = true;
+ }
+
+ private void SetGender(Gender newGender)
+ {
+ Profile = Profile?.WithGender(newGender);
+ IsDirty = true;
+ }
+
+ private void SetSpecies(string newSpecies)
+ {
+ Profile = Profile?.WithSpecies(newSpecies);
+ OnSkinColorOnValueChanged();
+ UpdateSexControls();
+ UpdateBodyTypes();
+ IsDirty = true;
+ }
+
+ private void SetName(string newName)
+ {
+ Profile = Profile?.WithName(newName);
+ IsDirty = true;
+ }
+
+ private void SetBodyType(string newBodyType)
+ {
+ Profile = Profile?.WithBodyType(newBodyType);
+ IsDirty = true;
+ }
+
+ #endregion
+
+ #region Update
+
+ private void UpdateSaveButton()
+ {
+ SaveButton.Disabled = Profile is null || !IsDirty;
+ }
+
+ private void UpdateNamesEdit()
+ {
+ NameEdit.Text = Profile?.Name ?? "";
+ }
+
+ private void UpdateGenderControls()
+ {
+ if (Profile == null)
+ return;
+
+ GenderButton.SelectId((int) Profile.Gender);
+ }
+
+ private void UpdateTtsVoicesControls()
+ {
+ if (Profile is null)
+ return;
+
+ var sponsorsManager = IoCManager.Resolve();
+
+ VoiceButton.Clear();
+
+ var firstVoiceChoiceId = 1;
+ for (var i = 0; i < _voiceList.Count; i++)
+ {
+ var voice = _voiceList[i];
+ if (!HumanoidCharacterProfile.CanHaveVoice(voice, Profile.Sex))
+ {
+ if (!sponsorsManager.TryGetInfo(out var sponsorInfo)
+ || !sponsorInfo.AllowedMarkings.Contains(AnySexVoiceProto))
+ continue;
+ }
+
+ var name = Loc.GetString(voice.Name);
+ VoiceButton.AddItem(name, i);
+
+ if (firstVoiceChoiceId == 1)
+ firstVoiceChoiceId = i;
+
+ if (voice.SponsorOnly &&
+ sponsorsManager.TryGetInfo(out var sponsor) &&
+ !sponsor.AllowedMarkings.Contains(voice.ID))
+ {
+ VoiceButton.SetItemDisabled(i, true);
+ }
+ }
+
+ var voiceChoiceId = _voiceList.FindIndex(x => x.ID == Profile.Voice);
+ if (!VoiceButton.TrySelectId(voiceChoiceId) &&
+ VoiceButton.TrySelectId(firstVoiceChoiceId))
+ {
+ SetVoice(_voiceList[firstVoiceChoiceId].ID);
+ }
+ }
+
+ private void UpdateSexControls()
+ {
+ if (Profile == null)
+ return;
+
+ SexButton.Clear();
+
+ var sexes = new List();
+
+ if (!_prototypeManager.TryIndex(Profile.Species, out var speciesProto))
+ sexes.Add(Sex.Unsexed);
+ else
+ sexes.AddRange(speciesProto.Sexes);
+
+ foreach (var sex in sexes)
+ {
+ SexButton.AddItem(Loc.GetString($"humanoid-profile-editor-sex-{sex.ToString().ToLower()}-text"), (int) sex);
+ }
+
+ if (sexes.Contains(Profile.Sex))
+ SexButton.SelectId((int) Profile.Sex);
+ else
+ SexButton.SelectId((int) sexes[0]);
+ }
+
+ private void UpdateBodyTypes()
+ {
+ if (Profile is null)
+ return;
+
+ BodyTypesButton.Clear();
+ var species = _prototypeManager.Index(Profile.Species);
+ var sex = Profile.Sex;
+ _bodyTypesList = EntitySystem.Get().GetValidBodyTypes(species, sex);
+
+ for (var i = 0; i < _bodyTypesList.Count; i++)
+ {
+ BodyTypesButton.AddItem(Loc.GetString(_bodyTypesList[i].Name), i);
+ }
+
+ if (!_bodyTypesList.Select(proto => proto.ID).Contains(Profile.BodyType.Id))
+ SetBodyType(_bodyTypesList.First().ID);
+
+ BodyTypesButton.Select(_bodyTypesList.FindIndex(x => x.ID == Profile.BodyType));
+ IsDirty = true;
+ }
+
+ private void UpdateHairPickers()
+ {
+ if (Profile == null)
+ return;
+
+ var hairMarking = Profile.Appearance.HairStyleId switch
+ {
+ HairStyles.DefaultHairStyle => new List(),
+ _ => new List { new(Profile.Appearance.HairStyleId, new List { Profile.Appearance.HairColor }) },
+ };
+
+ var facialHairMarking = Profile.Appearance.FacialHairStyleId switch
+ {
+ HairStyles.DefaultFacialHairStyle => new List(),
+ _ => new List { new(Profile.Appearance.FacialHairStyleId, new List { Profile.Appearance.FacialHairColor }) },
+ };
+
+ HairPicker.UpdateData(hairMarking, Profile.Species, 1);
+ FacialHairPicker.UpdateData(facialHairMarking, Profile.Species, 1);
+ }
+
+ private void UpdateCMarkingsFacialHair()
+ {
+ if (Profile == null)
+ return;
+
+ Color? facialHairColor = null;
+ if ( Profile.Appearance.FacialHairStyleId != HairStyles.DefaultFacialHairStyle &&
+ _markingManager.Markings.TryGetValue(Profile.Appearance.FacialHairStyleId, out var facialHairProto)
+ )
+ {
+ if (_markingManager.CanBeApplied(Profile.Species, Profile.Sex, facialHairProto, _prototypeManager))
+ {
+ facialHairColor = _markingManager.MustMatchSkin(Profile.BodyType, HumanoidVisualLayers.Hair, out _, _prototypeManager) ? Profile.Appearance.SkinColor : Profile.Appearance.FacialHairColor;
+ }
+ }
+ }
+
+ private void UpdateCMarkingsHair()
+ {
+ if (Profile == null)
+ return;
+
+ // hair color
+ Color? hairColor = null;
+ if ( Profile.Appearance.HairStyleId != HairStyles.DefaultHairStyle &&
+ _markingManager.Markings.TryGetValue(Profile.Appearance.HairStyleId, out var hairProto)
+ )
+ {
+ if (_markingManager.CanBeApplied(Profile.Species, Profile.Sex, hairProto, _prototypeManager))
+ {
+ hairColor = _markingManager.MustMatchSkin(Profile.BodyType, HumanoidVisualLayers.Hair, out _, _prototypeManager)
+ ? Profile.Appearance.SkinColor
+ : Profile.Appearance.HairColor;
+ }
+ }
+ }
+
+ private void UpdateSkinColor()
+ {
+ if (Profile == null)
+ return;
+
+ var skin = _prototypeManager.Index(Profile.Species).SkinColoration;
+
+ switch (skin)
+ {
+ case HumanoidSkinColor.HumanToned:
+ {
+ if (!SkinColorSlider.Visible)
+ {
+ SkinColorSlider.Visible = true;
+ _rgbSkinColorSelector.Visible = false;
+ }
+
+ SkinColorSlider.Value = SkinColor.HumanSkinToneFromColor(Profile.Appearance.SkinColor);
+
+ break;
+ }
+ case HumanoidSkinColor.Hues:
+ {
+ if (!_rgbSkinColorSelector.Visible)
+ {
+ SkinColorSlider.Visible = false;
+ _rgbSkinColorSelector.Visible = true;
+ }
+
+ // set the RGB values to the direct values otherwise
+ _rgbSkinColorSelector.Color = Profile.Appearance.SkinColor;
+ break;
+ }
+ case HumanoidSkinColor.TintedHues:
+ {
+ if (!_rgbSkinColorSelector.Visible)
+ {
+ SkinColorSlider.Visible = false;
+ _rgbSkinColorSelector.Visible = true;
+ }
+
+ // set the RGB values to the direct values otherwise
+ _rgbSkinColorSelector.Color = Profile.Appearance.SkinColor;
+ break;
+ }
+ }
+ }
+
+ private void UpdateSpecies()
+ {
+ if (Profile == null)
+ {
+ return;
+ }
+
+ if (!_speciesList.Exists(x => x.ID == Profile.Species))
+ {
+ SpeciesButton.Select(0);
+ return;
+ }
+
+ SpeciesButton.Select(_speciesList.FindIndex(x => x.ID == Profile.Species));
+ }
+
+ private void UpdateAgeEdit()
+ {
+ AgeEdit.Text = Profile?.Age.ToString() ?? "";
+ }
+
+ private void UpdateEyePickers()
+ {
+ if (Profile == null)
+ {
+ return;
+ }
+
+ EyesPicker.SetData(Profile.Appearance.EyeColor);
+ }
+
+ #endregion
+
+ private void OnSkinColorOnValueChanged()
+ {
+ if (Profile is null)
+ return;
+
+ var skin = _prototypeManager.Index(Profile.Species).SkinColoration;
+
+ switch (skin)
+ {
+ case HumanoidSkinColor.HumanToned:
+ {
+ if (!SkinColorSlider.Visible)
+ {
+ SkinColorSlider.Visible = true;
+ RgbSkinColorContainer.Visible = false;
+ }
+
+ var color = SkinColor.HumanSkinTone((int) SkinColorSlider.Value);
+
+ Profile = Profile.WithCharacterAppearance(Profile.Appearance.WithSkinColor(color));//
+ break;
+ }
+ case HumanoidSkinColor.Hues:
+ {
+ if (!RgbSkinColorContainer.Visible)
+ {
+ SkinColorSlider.Visible = false;
+ RgbSkinColorContainer.Visible = true;
+ }
+
+ Profile = Profile.WithCharacterAppearance(Profile.Appearance.WithSkinColor(_rgbSkinColorSelector.Color));
+ break;
+ }
+ case HumanoidSkinColor.TintedHues:
+ {
+ if (!RgbSkinColorContainer.Visible)
+ {
+ SkinColorSlider.Visible = false;
+ RgbSkinColorContainer.Visible = true;
+ }
+
+ var color = SkinColor.TintedHues(_rgbSkinColorSelector.Color);
+
+ Profile = Profile.WithCharacterAppearance(Profile.Appearance.WithSkinColor(color));
+ break;
+ }
+ }
+
+ IsDirty = true;
+ }
+
+ private void OnBodyTypeSelected(OptionButton.ItemSelectedEventArgs args)
+ {
+ args.Button.SelectId(args.Id);
+ SetBodyType(_bodyTypesList[args.Id].ID);
+ }
+
+ private bool IsDirty
+ {
+ get => _isDirty;
+ set
+ {
+ _isDirty = value;
+ UpdateSaveButton();
+ }
+ }
+
+ public void UpdateState(WizardMirrorUiState state)
+ {
+ Profile = state.Profile;
+
+ UpdateNamesEdit();
+ UpdateSexControls();
+ UpdateGenderControls();
+ UpdateSkinColor();
+ UpdateSpecies();
+ UpdateAgeEdit();
+ UpdateEyePickers();
+ UpdateSaveButton();
+ UpdateHairPickers();
+ UpdateCMarkingsHair();
+ UpdateCMarkingsFacialHair();
+ UpdateTtsVoicesControls();
+ UpdateBodyTypes();
+ }
+}
diff --git a/Content.Client/_White/Wizard/Mirror/WizardMirrorWindow.xaml b/Content.Client/_White/Wizard/Mirror/WizardMirrorWindow.xaml
new file mode 100644
index 0000000000..64258d2f19
--- /dev/null
+++ b/Content.Client/_White/Wizard/Mirror/WizardMirrorWindow.xaml
@@ -0,0 +1,85 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Content.Server/MagicMirror/MagicMirrorSystem.cs b/Content.Server/MagicMirror/MagicMirrorSystem.cs
index a3cb98c9ed..970a7bfc6a 100644
--- a/Content.Server/MagicMirror/MagicMirrorSystem.cs
+++ b/Content.Server/MagicMirror/MagicMirrorSystem.cs
@@ -357,4 +357,4 @@ public sealed class MagicMirrorSystem : EntitySystem
{
ent.Comp.Target = null;
}
-}
\ No newline at end of file
+}
diff --git a/Content.Server/_White/Wizard/Mirror/WizardMirrorSystem.cs b/Content.Server/_White/Wizard/Mirror/WizardMirrorSystem.cs
new file mode 100644
index 0000000000..64e643dec1
--- /dev/null
+++ b/Content.Server/_White/Wizard/Mirror/WizardMirrorSystem.cs
@@ -0,0 +1,137 @@
+using Content.Server.Humanoid;
+using Content.Server.IdentityManagement;
+using Content.Shared._White.Wizard.Mirror;
+using Content.Shared.Humanoid;
+using Content.Shared.Humanoid.Markings;
+using Content.Shared.Interaction;
+using Content.Shared.Physics;
+using Content.Shared.Preferences;
+using Content.Shared.UserInterface;
+using Robust.Server.GameObjects;
+using Robust.Shared.Player;
+
+namespace Content.Server._White.Wizard.Mirror;
+
+public sealed class WizardMirrorSystem : EntitySystem
+{
+ [Dependency] private readonly HumanoidAppearanceSystem _humanoid = default!;
+ [Dependency] private readonly UserInterfaceSystem _uiSystem = default!;
+ [Dependency] private readonly SharedInteractionSystem _interaction = default!;
+ [Dependency] private readonly MetaDataSystem _metaData = default!;
+ [Dependency] private readonly IdentitySystem _identity = default!;
+
+ public override void Initialize()
+ {
+ base.Initialize();
+ SubscribeLocalEvent(OnOpenUIAttempt);
+
+ Subs.BuiEvents(WizardMirrorUiKey.Key,
+ subs =>
+ {
+ subs.Event(OnUIClosed);
+ subs.Event(OnSave);
+ });
+
+ SubscribeLocalEvent(OnInteractHand);
+ SubscribeLocalEvent(OnMagicMirrorInteract);
+
+ SubscribeLocalEvent(OnRangeCheck);
+ }
+
+ private void OnOpenUIAttempt(EntityUid uid, WizardMirrorComponent mirror, ActivatableUIOpenAttemptEvent args)
+ {
+ if (!HasComp(args.User))
+ args.Cancel();
+ }
+
+ private static void OnUIClosed(Entity ent, ref BoundUIClosedEvent args)
+ {
+ ent.Comp.Target = null;
+ }
+
+ private void OnSave(EntityUid uid, WizardMirrorComponent component, WizardMirrorSave args)
+ {
+ if (!TryComp(component.Target, out HumanoidAppearanceComponent? humanoid) || !string.IsNullOrEmpty(humanoid.Initial))
+ return;
+
+ _humanoid.LoadProfile(component.Target.Value, args.Profile, humanoid);
+ _metaData.SetEntityName(component.Target.Value, args.Profile.Name);
+ _identity.QueueIdentityUpdate(component.Target.Value);
+ }
+
+ private void OnInteractHand(EntityUid uid, WizardMirrorComponent component, ref InteractHandEvent args)
+ {
+ UpdateInterface(uid, args.User, component);
+ }
+
+ private void OnMagicMirrorInteract(EntityUid uid, WizardMirrorComponent component, ref AfterInteractEvent args)
+ {
+ if (!args.CanReach || args.Target == null)
+ return;
+
+ if (!TryComp(args.User, out var actor))
+ return;
+
+ if (!_uiSystem.TryOpen(uid, WizardMirrorUiKey.Key, actor.PlayerSession))
+ return;
+
+ UpdateInterface(uid, args.Target.Value, component);
+ }
+
+ private void OnRangeCheck(EntityUid uid, WizardMirrorComponent component, ref BoundUserInterfaceCheckRangeEvent args)
+ {
+ component.Target ??= args.Player.AttachedEntity;
+
+ if (!component.Target.HasValue || !_interaction.InRangeUnobstructed(uid, component.Target!.Value, range: 2f, CollisionGroup.None))
+ args.Result = BoundUserInterfaceRangeResult.Fail;
+ }
+
+ private void UpdateInterface(EntityUid mirrorUid, EntityUid targetUid, WizardMirrorComponent component)
+ {
+ if (!TryComp(targetUid, out var humanoid) ||
+ !TryComp(targetUid, out var meta))
+ return;
+
+ var hair = humanoid.MarkingSet.TryGetCategory(MarkingCategories.Hair, out var hairMarkings)
+ ? new List(hairMarkings)[0]
+ : null;
+
+ var facialHair = humanoid.MarkingSet.TryGetCategory(MarkingCategories.FacialHair, out var facialHairMarkings)
+ ? new List(facialHairMarkings)[0]
+ : null;
+
+ var profile = HumanoidCharacterProfile.RandomWithSpecies(humanoid.Species)
+ .WithAge(humanoid.Age)
+ .WithGender(humanoid.Gender)
+ .WithName(meta.EntityName)
+ .WithSex(humanoid.Sex)
+ .WithVoice(humanoid.Voice)
+ .WithBodyType(humanoid.BodyType);
+
+ profile = profile.WithCharacterAppearance(
+ profile.WithCharacterAppearance(
+ profile.Appearance.WithSkinColor(humanoid.SkinColor))
+ .Appearance.WithEyeColor(humanoid.EyeColor));
+
+ if (hair != null)
+ {
+ profile = profile.WithCharacterAppearance(
+ profile.WithCharacterAppearance(
+ profile.Appearance.WithHairStyleName(hair.MarkingId))
+ .Appearance.WithHairColor(hair.MarkingColors[0]));
+ }
+
+ if (facialHair != null)
+ {
+ profile = profile.WithCharacterAppearance(
+ profile.WithCharacterAppearance(
+ profile.Appearance.WithFacialHairStyleName(facialHair.MarkingId))
+ .Appearance.WithFacialHairColor(facialHair.MarkingColors[0]));
+ }
+
+ var state = new WizardMirrorUiState(profile);
+
+ component.Target = targetUid;
+ _uiSystem.TrySetUiState(mirrorUid, WizardMirrorUiKey.Key, state);
+ }
+}
diff --git a/Content.Server/_White/Wizard/WizardComponent.cs b/Content.Server/_White/Wizard/WizardComponent.cs
index 6d236c075f..8636bb84fd 100644
--- a/Content.Server/_White/Wizard/WizardComponent.cs
+++ b/Content.Server/_White/Wizard/WizardComponent.cs
@@ -6,4 +6,22 @@ public sealed partial class WizardComponent : Component
{
[ViewVariables(VVAccess.ReadWrite)]
public bool EndRoundOnDeath;
+
+ [DataField]
+ public int MinAge = 90;
+
+ [DataField]
+ public int MaxAge = 170;
+
+ [DataField]
+ public string Hair = "WizardHair";
+
+ [DataField]
+ public string FacialHair = "WizardFacialHair";
+
+ [DataField]
+ public string Color = "WizardHairColor";
+
+ [DataField]
+ public string Name = "WizardNames";
}
diff --git a/Content.Server/_White/Wizard/WizardRuleSystem.cs b/Content.Server/_White/Wizard/WizardRuleSystem.cs
index 1d04de201c..da3b3c3289 100644
--- a/Content.Server/_White/Wizard/WizardRuleSystem.cs
+++ b/Content.Server/_White/Wizard/WizardRuleSystem.cs
@@ -25,6 +25,7 @@ using Content.Server.Objectives;
using Content.Server.Station.Components;
using Content.Server.StationEvents.Components;
using Content.Shared._White.Antag;
+using Content.Shared.Dataset;
using Content.Shared.Mind;
using Content.Shared.NPC.Components;
using Content.Shared.Objectives.Components;
@@ -55,6 +56,7 @@ public sealed class WizardRuleSystem : GameRuleSystem
[Dependency] private readonly RoundEndSystem _roundEndSystem = default!;
[Dependency] private readonly ObjectivesSystem _objectives = default!;
+
private ISawmill _sawmill = default!;
///
@@ -137,10 +139,6 @@ public sealed class WizardRuleSystem : GameRuleSystem
if (!TryComp(spawner, out var wizardSpawner))
return;
- HumanoidCharacterProfile? profile = null;
- if (TryComp(args.Spawned, out ActorComponent? actor))
- profile = _prefs.GetPreferences(actor.PlayerSession.UserId).SelectedCharacter as HumanoidCharacterProfile;
-
if (!EntityQuery().Any())
return;
@@ -150,7 +148,7 @@ public sealed class WizardRuleSystem : GameRuleSystem
return;
}
- SetupWizardEntity(uid, gear, profile, false);
+ SetupWizardEntity(uid, gear, false);
}
private void OnMindAdded(EntityUid uid, WizardComponent component, MindAddedMessage args)
@@ -279,25 +277,40 @@ public sealed class WizardRuleSystem : GameRuleSystem
return true;
}
- private void SetupWizardEntity(
+ private HumanoidCharacterProfile SetupWizardEntity(
EntityUid mob,
StartingGearPrototype gear,
- HumanoidCharacterProfile? profile,
bool endRoundOnDeath)
{
- EnsureComp(mob).EndRoundOnDeath = endRoundOnDeath;
+ EnsureComp(mob, out var component);
+ component.EndRoundOnDeath = endRoundOnDeath;
EnsureComp(mob).AntagonistPrototype = "globalAntagonistWizard";
- profile ??= HumanoidCharacterProfile.RandomWithSpecies();
+ var random = IoCManager.Resolve();
+ var profile = HumanoidCharacterProfile.RandomWithSpecies().WithAge(random.Next(component.MinAge, component.MaxAge));
+
+ var color = Color.FromHex(GetRandom(component.Color, "#B5B8B1"));
+ var hair = GetRandom(component.Hair, "HumanHairAfricanPigtails");
+ var facialHair = GetRandom(component.FacialHair, "HumanFacialHairAbe");
+ profile = profile.WithCharacterAppearance(
+ profile.WithCharacterAppearance(
+ profile.WithCharacterAppearance(
+ profile.WithCharacterAppearance(
+ profile.Appearance.WithHairStyleName(hair))
+ .Appearance.WithFacialHairStyleName(facialHair))
+ .Appearance.WithHairColor(color))
+ .Appearance.WithFacialHairColor(color));
_humanoid.LoadProfile(mob, profile);
- _metaData.SetEntityName(mob, profile.Name);
+ _metaData.SetEntityName(mob, GetRandom(component.Name, ""));
_stationSpawning.EquipStartingGear(mob, gear);
_npcFaction.RemoveFaction(mob, "NanoTrasen", false);
_npcFaction.AddFaction(mob, "Wizard");
+
+ return profile;
}
private EntityCoordinates WizardSpawnPoint(WizardRuleComponent component)
@@ -342,13 +355,7 @@ public sealed class WizardRuleSystem : GameRuleSystem
//If a session is available, spawn mob and transfer mind into it
if (session != null)
{
- var profile =
- _prefs.GetPreferences(session.UserId).SelectedCharacter as HumanoidCharacterProfile;
-
- profile ??= HumanoidCharacterProfile.RandomWithSpecies();
- var name = profile.Name;
-
- if (!_prototypeManager.TryIndex(profile.Species, out SpeciesPrototype? species))
+ if (!_prototypeManager.TryIndex(SharedHumanoidAppearanceSystem.DefaultSpecies, out SpeciesPrototype? species))
{
species = _prototypeManager.Index(SharedHumanoidAppearanceSystem.DefaultSpecies);
}
@@ -360,7 +367,7 @@ public sealed class WizardRuleSystem : GameRuleSystem
return;
}
- SetupWizardEntity(mob, gear, profile, true);
+ var name = SetupWizardEntity(mob, gear, true).Name;
var newMind = _mind.CreateMind(session.UserId, name);
_mind.SetUserId(newMind, session.UserId);
@@ -442,10 +449,6 @@ public sealed class WizardRuleSystem : GameRuleSystem
return false;
}
- HumanoidCharacterProfile? profile = null;
- if (TryComp(wizard, out ActorComponent? actor))
- profile = _prefs.GetPreferences(actor.PlayerSession.UserId).SelectedCharacter as HumanoidCharacterProfile;
-
if (giveObjectives)
{
AddRole(mindId, mind, rule);
@@ -457,7 +460,7 @@ public sealed class WizardRuleSystem : GameRuleSystem
return false;
}
- SetupWizardEntity(wizard, gear, profile, false);
+ SetupWizardEntity(wizard, gear, false);
var spawnpoint = WizardSpawnPoint(rule);
var transform = EnsureComp(wizard);
@@ -465,4 +468,11 @@ public sealed class WizardRuleSystem : GameRuleSystem
return true;
}
+
+ private string GetRandom(string list, string ifNull)
+ {
+ return _prototypeManager.TryIndex(list, out var prototype)
+ ? _random.Pick(prototype.Values)
+ : ifNull;
+ }
}
diff --git a/Content.Shared/_White/Wizard/Mirror/SharedWizardMirrorSystem.cs b/Content.Shared/_White/Wizard/Mirror/SharedWizardMirrorSystem.cs
new file mode 100644
index 0000000000..c44624fdea
--- /dev/null
+++ b/Content.Shared/_White/Wizard/Mirror/SharedWizardMirrorSystem.cs
@@ -0,0 +1,29 @@
+using Content.Shared.Humanoid;
+using Content.Shared.Humanoid.Markings;
+using Content.Shared.Preferences;
+using Robust.Shared.Enums;
+using Robust.Shared.Serialization;
+
+namespace Content.Shared._White.Wizard.Mirror;
+
+[Serializable, NetSerializable]
+public enum WizardMirrorUiKey : byte
+{
+ Key
+}
+
+[Serializable, NetSerializable]
+public sealed class WizardMirrorSave(HumanoidCharacterProfile profile) : BoundUserInterfaceMessage
+{
+ public HumanoidCharacterProfile Profile { get; } = profile;
+}
+
+[Serializable, NetSerializable]
+public sealed class WizardMirrorUiState(
+ HumanoidCharacterProfile profile)
+ : BoundUserInterfaceState
+{
+ public NetEntity Target;
+
+ public HumanoidCharacterProfile Profile = profile;
+}
diff --git a/Content.Shared/_White/Wizard/Mirror/WizardMirrorComponent.cs b/Content.Shared/_White/Wizard/Mirror/WizardMirrorComponent.cs
new file mode 100644
index 0000000000..5d89378a6f
--- /dev/null
+++ b/Content.Shared/_White/Wizard/Mirror/WizardMirrorComponent.cs
@@ -0,0 +1,8 @@
+namespace Content.Shared._White.Wizard.Mirror;
+
+[RegisterComponent]
+public sealed partial class WizardMirrorComponent : Component
+{
+ [DataField]
+ public EntityUid? Target;
+}
diff --git a/Resources/Locale/ru-RU/_white/wizard/mirror.ftl b/Resources/Locale/ru-RU/_white/wizard/mirror.ftl
new file mode 100644
index 0000000000..907e95cfc6
--- /dev/null
+++ b/Resources/Locale/ru-RU/_white/wizard/mirror.ftl
@@ -0,0 +1,2 @@
+ent-MagicMirror = волшебное зеркало
+ .desc = Свет мой, зеркальце, скажи, да всю правду доложи, я ль робастней всех на свете?
\ No newline at end of file
diff --git a/Resources/Maps/White/Shuttles/wizard.yml b/Resources/Maps/White/Shuttles/wizard.yml
index 0ec7a8e005..438d273c74 100644
--- a/Resources/Maps/White/Shuttles/wizard.yml
+++ b/Resources/Maps/White/Shuttles/wizard.yml
@@ -23,24 +23,12 @@ tilemap:
entities:
- proto: ""
entities:
- - uid: 1
- components:
- - type: MetaData
- name: map 2
- - type: Transform
- - type: Map
- - type: PhysicsMap
- - type: GridTree
- - type: MovedGrids
- - type: Broadphase
- - type: OccluderTree
- - type: LoadedMap
- uid: 2
components:
- type: MetaData
- type: Transform
pos: 0.3842575,0.4217209
- parent: 1
+ parent: invalid
- type: MapGrid
chunks:
-1,-1:
@@ -3093,6 +3081,23 @@ entities:
- type: Transform
pos: -9.508206,-3.3199291
parent: 2
+- proto: MagicMirror
+ entities:
+ - uid: 1
+ components:
+ - type: Transform
+ pos: 2.5,-8.5
+ parent: 2
+ - uid: 449
+ components:
+ - type: Transform
+ pos: -2.5,1.5
+ parent: 2
+ - uid: 450
+ components:
+ - type: Transform
+ pos: 1.5,1.5
+ parent: 2
- proto: MedkitAdvancedFilled
entities:
- uid: 447
@@ -3107,23 +3112,6 @@ entities:
- type: Transform
pos: -2.3338752,-0.5901818
parent: 2
-- proto: Mirror
- entities:
- - uid: 449
- components:
- - type: Transform
- pos: 2.5,-8.5
- parent: 2
- - uid: 450
- components:
- - type: Transform
- pos: 1.5,1.5
- parent: 2
- - uid: 451
- components:
- - type: Transform
- pos: -2.5,1.5
- parent: 2
- proto: NitrogenCanister
entities:
- uid: 452
diff --git a/Resources/Prototypes/_White/Entities/Structures/Wallmounts/mirror.yml b/Resources/Prototypes/_White/Entities/Structures/Wallmounts/mirror.yml
new file mode 100644
index 0000000000..ea8eeb4678
--- /dev/null
+++ b/Resources/Prototypes/_White/Entities/Structures/Wallmounts/mirror.yml
@@ -0,0 +1,21 @@
+- type: entity
+ id: MagicMirror
+ name: magic mirror
+ description: 'Mirror mirror on the wall , who''s the most robust of them all?'
+ components:
+ - type: WallMount
+ - type: Sprite
+ sprite: Structures/Wallmounts/mirror.rsi
+ state: mirror
+ - type: InteractionOutline
+ - type: Clickable
+ - type: Transform
+ anchored: true
+ - type: WizardMirror
+ - type: ActivatableUI
+ key: enum.WizardMirrorUiKey.Key
+ singleUser: true
+ - type: UserInterface
+ interfaces:
+ - key: enum.WizardMirrorUiKey.Key
+ type: WizardMirrorBoundUserInterface
diff --git a/Resources/Prototypes/_White/Wizard/Appearance/facial_hair.yml b/Resources/Prototypes/_White/Wizard/Appearance/facial_hair.yml
new file mode 100644
index 0000000000..40dbd22ed7
--- /dev/null
+++ b/Resources/Prototypes/_White/Wizard/Appearance/facial_hair.yml
@@ -0,0 +1,13 @@
+- type: dataset
+ id: WizardFacialHair
+ values:
+ - HumanFacialHairLongbeard
+ - HumanFacialHairMoonshiner
+ - HumanFacialHairVandyke
+ - HumanFacialHairMartialartist
+ - HumanFacialHairMutton
+ - HumanFacialHairLongbeard
+ - HumanFacialHairDwarf
+ - HumanFacialHairWise
+ - HumanFacialHairWatson
+ - HumanFacialHairChinlessbeard
\ No newline at end of file
diff --git a/Resources/Prototypes/_White/Wizard/Appearance/hair.yml b/Resources/Prototypes/_White/Wizard/Appearance/hair.yml
new file mode 100644
index 0000000000..175a07e6f7
--- /dev/null
+++ b/Resources/Prototypes/_White/Wizard/Appearance/hair.yml
@@ -0,0 +1,14 @@
+- type: dataset
+ id: WizardHair
+ values:
+ - HumanHair80s
+ - HumanHairFeather
+ - HumanHairBun
+ - HumanHairTopknot
+ - HumanHairBalding
+ - HumanHairBigflattop
+ - HumanHairScully
+ - HumanHairBeehivev2
+ - HumanHairSpikey
+ - HumanHairSwept2
+ - HumanHairGloomyLong
\ No newline at end of file
diff --git a/Resources/Prototypes/_White/Wizard/Appearance/hair_color.yml b/Resources/Prototypes/_White/Wizard/Appearance/hair_color.yml
new file mode 100644
index 0000000000..9d2de7abdd
--- /dev/null
+++ b/Resources/Prototypes/_White/Wizard/Appearance/hair_color.yml
@@ -0,0 +1,13 @@
+- type: dataset
+ id: WizardHairColor
+ values:
+ - "#B5B8B1"
+ - "#4E5754"
+ - "#A5A5A5"
+ - "#676767"
+ - "#CDCDCD"
+ - "#CCCCCC"
+ - "#7C7C7C"
+ - "#747474"
+ - "#6E6E6E"
+ - "#8F8F8F"
\ No newline at end of file
diff --git a/Resources/Prototypes/_White/Wizard/Appearance/names.yml b/Resources/Prototypes/_White/Wizard/Appearance/names.yml
new file mode 100644
index 0000000000..ff57b0c8c0
--- /dev/null
+++ b/Resources/Prototypes/_White/Wizard/Appearance/names.yml
@@ -0,0 +1,13 @@
+- type: dataset
+ id: WizardNames
+ values:
+ - Всеволод Всезнающий
+ - Варфоломей
+ - Тур'озиф
+ - Джоглот Белый
+ - Дедетер Серый
+ - Хасеалиан
+ - Граф Оганар
+ - Вольфганг Могучий
+ - Чистополк Велекодушный
+ - Грарон Жаждующий
\ No newline at end of file