diff --git a/Content.Client/Lobby/LobbyUIController.cs b/Content.Client/Lobby/LobbyUIController.cs
index 824a842d56..1cdaaccc4e 100644
--- a/Content.Client/Lobby/LobbyUIController.cs
+++ b/Content.Client/Lobby/LobbyUIController.cs
@@ -272,6 +272,7 @@ public sealed class LobbyUIController : UIController, IOnStateEntered
+
+
diff --git a/Content.Client/Lobby/UI/HumanoidProfileEditor.xaml.cs b/Content.Client/Lobby/UI/HumanoidProfileEditor.xaml.cs
index 87ef41c0b7..1509a2fed1 100644
--- a/Content.Client/Lobby/UI/HumanoidProfileEditor.xaml.cs
+++ b/Content.Client/Lobby/UI/HumanoidProfileEditor.xaml.cs
@@ -6,6 +6,7 @@ using Content.Client.Lobby.UI.Loadouts;
using Content.Client.Lobby.UI.Roles;
using Content.Client.Message;
using Content.Client.Players.PlayTimeTracking;
+using Content.Client.Sprite;
using Content.Client.Stylesheets;
using Content.Client.UserInterface.Systems.Guidebook;
using Content.Shared.CCVar;
@@ -27,6 +28,7 @@ using Robust.Client.UserInterface.Controls;
using Robust.Client.UserInterface.XAML;
using Robust.Client.Utility;
using Robust.Shared.Configuration;
+using Robust.Shared.ContentPack;
using Robust.Shared.Enums;
using Robust.Shared.Prototypes;
using Robust.Shared.Utility;
@@ -43,6 +45,7 @@ namespace Content.Client.Lobby.UI
private readonly IFileDialogManager _dialogManager;
private readonly IPlayerManager _playerManager;
private readonly IPrototypeManager _prototypeManager;
+ private readonly IResourceManager _resManager;
private readonly MarkingManager _markingManager;
private readonly JobRequirementsManager _requirements;
private readonly LobbyUIController _controller;
@@ -54,6 +57,7 @@ namespace Content.Client.Lobby.UI
private LoadoutWindow? _loadoutWindow;
private bool _exporting;
+ private bool _imaging;
///
/// If we're attempting to save.
@@ -107,6 +111,7 @@ namespace Content.Client.Lobby.UI
ILogManager logManager,
IPlayerManager playerManager,
IPrototypeManager prototypeManager,
+ IResourceManager resManager,
JobRequirementsManager requirements,
MarkingManager markings)
{
@@ -119,6 +124,7 @@ namespace Content.Client.Lobby.UI
_prototypeManager = prototypeManager;
_markingManager = markings;
_preferencesManager = preferencesManager;
+ _resManager = resManager;
_requirements = requirements;
_controller = UserInterfaceManager.GetUIController();
@@ -132,6 +138,16 @@ namespace Content.Client.Lobby.UI
ExportProfile();
};
+ ExportImageButton.OnPressed += args =>
+ {
+ ExportImage();
+ };
+
+ OpenImagesButton.OnPressed += args =>
+ {
+ _resManager.UserData.OpenOsWindow(ContentSpriteSystem.Exports);
+ };
+
ResetButton.OnPressed += args =>
{
SetProfile((HumanoidCharacterProfile?) _preferencesManager.Preferences?.SelectedCharacter, _preferencesManager.Preferences?.SelectedCharacterIndex);
@@ -424,7 +440,6 @@ namespace Content.Client.Lobby.UI
SpeciesInfoButton.OnPressed += OnSpeciesInfoButtonPressed;
UpdateSpeciesGuidebookIcon();
- ReloadPreview();
IsDirty = false;
}
@@ -697,11 +712,12 @@ namespace Content.Client.Lobby.UI
_entManager.DeleteEntity(PreviewDummy);
PreviewDummy = EntityUid.Invalid;
- if (Profile == null || !_prototypeManager.HasIndex(Profile.Species))
+ if (Profile == null || !_prototypeManager.HasIndex(Profile.Species))
return;
PreviewDummy = _controller.LoadProfileEntity(Profile, JobOverride, ShowClothes.Pressed);
SpriteView.SetEntity(PreviewDummy);
+ _entManager.System().SetEntityName(PreviewDummy, Profile.Name);
}
///
@@ -1122,6 +1138,17 @@ namespace Content.Client.Lobby.UI
_loadoutWindow?.Dispose();
_loadoutWindow = null;
+ }
+
+ protected override void EnteredTree()
+ {
+ base.EnteredTree();
+ ReloadPreview();
+ }
+
+ protected override void ExitedTree()
+ {
+ base.ExitedTree();
_entManager.DeleteEntity(PreviewDummy);
PreviewDummy = EntityUid.Invalid;
}
@@ -1182,6 +1209,11 @@ namespace Content.Client.Lobby.UI
{
Profile = Profile?.WithName(newName);
SetDirty();
+
+ if (!IsDirty)
+ return;
+
+ _entManager.System().SetEntityName(PreviewDummy, newName);
}
private void SetSpawnPriority(SpawnPriorityPreference newSpawnPriority)
@@ -1513,6 +1545,19 @@ namespace Content.Client.Lobby.UI
UpdateNameEdit();
}
+ private async void ExportImage()
+ {
+ if (_imaging)
+ return;
+
+ var dir = SpriteView.OverrideDirection ?? Direction.South;
+
+ // I tried disabling the button but it looks sorta goofy as it only takes a frame or two to save
+ _imaging = true;
+ await _entManager.System().Export(PreviewDummy, dir, includeId: false);
+ _imaging = false;
+ }
+
private async void ImportProfile()
{
if (_exporting || CharacterSlot == null || Profile == null)
diff --git a/Content.Client/Sprite/ContentSpriteSystem.cs b/Content.Client/Sprite/ContentSpriteSystem.cs
new file mode 100644
index 0000000000..da0547666b
--- /dev/null
+++ b/Content.Client/Sprite/ContentSpriteSystem.cs
@@ -0,0 +1,218 @@
+using System.IO;
+using System.Numerics;
+using System.Threading;
+using System.Threading.Tasks;
+using Content.Client.Administration.Managers;
+using Content.Shared.Database;
+using Content.Shared.Verbs;
+using Robust.Client.GameObjects;
+using Robust.Client.Graphics;
+using Robust.Client.UserInterface;
+using Robust.Shared.ContentPack;
+using Robust.Shared.Timing;
+using Robust.Shared.Utility;
+using SixLabors.ImageSharp;
+using SixLabors.ImageSharp.PixelFormats;
+using Color = Robust.Shared.Maths.Color;
+
+namespace Content.Client.Sprite;
+
+public sealed class ContentSpriteSystem : EntitySystem
+{
+ [Dependency] private readonly IClientAdminManager _adminManager = default!;
+ [Dependency] private readonly IClyde _clyde = default!;
+ [Dependency] private readonly IGameTiming _timing = default!;
+ [Dependency] private readonly IResourceManager _resManager = default!;
+ [Dependency] private readonly IUserInterfaceManager _ui = default!;
+
+ private ContentSpriteControl _control = new();
+
+ public static readonly ResPath Exports = new ResPath("/Exports");
+
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ _resManager.UserData.CreateDir(Exports);
+ _ui.RootControl.AddChild(_control);
+ SubscribeLocalEvent>(GetVerbs);
+ }
+
+ public override void Shutdown()
+ {
+ base.Shutdown();
+
+ foreach (var queued in _control._queuedTextures)
+ {
+ queued.Tcs.SetCanceled();
+ }
+
+ _control._queuedTextures.Clear();
+
+ _ui.RootControl.RemoveChild(_control);
+ }
+
+ ///
+ /// Exports sprites for all directions
+ ///
+ public async Task Export(EntityUid entity, bool includeId = true, CancellationToken cancelToken = default)
+ {
+ var tasks = new Task[4];
+ var i = 0;
+
+ foreach (var dir in new Direction[]
+ {
+ Direction.South,
+ Direction.East,
+ Direction.North,
+ Direction.West,
+ })
+ {
+ tasks[i++] = Export(entity, dir, includeId: includeId, cancelToken);
+ }
+
+ await Task.WhenAll(tasks);
+ }
+
+ ///
+ /// Exports the sprite for a particular direction.
+ ///
+ public async Task Export(EntityUid entity, Direction direction, bool includeId = true, CancellationToken cancelToken = default)
+ {
+ if (!_timing.IsFirstTimePredicted)
+ return;
+
+ if (!TryComp(entity, out SpriteComponent? spriteComp))
+ return;
+
+ // Don't want to wait for engine pr
+ var size = Vector2i.Zero;
+
+ foreach (var layer in spriteComp.AllLayers)
+ {
+ if (!layer.Visible)
+ continue;
+
+ size = Vector2i.ComponentMax(size, layer.PixelSize);
+ }
+
+ // Stop asserts
+ if (size.Equals(Vector2i.Zero))
+ return;
+
+ var texture = _clyde.CreateRenderTarget(new Vector2i(size.X, size.Y), new RenderTargetFormatParameters(RenderTargetColorFormat.Rgba8Srgb), name: "export");
+ var tcs = new TaskCompletionSource(cancelToken);
+
+ _control._queuedTextures.Enqueue((texture, direction, entity, includeId, tcs));
+
+ await tcs.Task;
+ }
+
+ private void GetVerbs(GetVerbsEvent ev)
+ {
+ if (!_adminManager.IsAdmin())
+ return;
+
+ Verb verb = new()
+ {
+ Text = Loc.GetString("export-entity-verb-get-data-text"),
+ Category = VerbCategory.Debug,
+ Act = () =>
+ {
+ Export(ev.Target);
+ },
+ };
+
+ ev.Verbs.Add(verb);
+ }
+
+ ///
+ /// This is horrible. I asked PJB if there's an easy way to render straight to a texture outside of the render loop
+ /// and she also mentioned this as a bad possibility.
+ ///
+ private sealed class ContentSpriteControl : Control
+ {
+ [Dependency] private readonly IEntityManager _entManager = default!;
+ [Dependency] private readonly ILogManager _logMan = default!;
+ [Dependency] private readonly IResourceManager _resManager = default!;
+
+ internal Queue<(
+ IRenderTexture Texture,
+ Direction Direction,
+ EntityUid Entity,
+ bool IncludeId,
+ TaskCompletionSource Tcs)> _queuedTextures = new();
+
+ private ISawmill _sawmill;
+
+ public ContentSpriteControl()
+ {
+ IoCManager.InjectDependencies(this);
+ _sawmill = _logMan.GetSawmill("sprite.export");
+ }
+
+ protected override void Draw(DrawingHandleScreen handle)
+ {
+ base.Draw(handle);
+
+ while (_queuedTextures.TryDequeue(out var queued))
+ {
+ if (queued.Tcs.Task.IsCanceled)
+ continue;
+
+ try
+ {
+ if (!_entManager.TryGetComponent(queued.Entity, out MetaDataComponent? metadata))
+ continue;
+
+ var filename = metadata.EntityName;
+ var result = queued;
+
+ handle.RenderInRenderTarget(queued.Texture, () =>
+ {
+ handle.DrawEntity(result.Entity, result.Texture.Size / 2, Vector2.One, Angle.Zero,
+ overrideDirection: result.Direction);
+ }, Color.Transparent);
+
+ ResPath fullFileName;
+
+ if (queued.IncludeId)
+ {
+ fullFileName = Exports / $"{filename}-{queued.Direction}-{queued.Entity}.png";
+ }
+ else
+ {
+ fullFileName = Exports / $"{filename}-{queued.Direction}.png";
+ }
+
+ queued.Texture.CopyPixelsToMemory(image =>
+ {
+ if (_resManager.UserData.Exists(fullFileName))
+ {
+ _sawmill.Info($"Found existing file {fullFileName} to replace.");
+ _resManager.UserData.Delete(fullFileName);
+ }
+
+ using var file =
+ _resManager.UserData.Open(fullFileName, FileMode.CreateNew, FileAccess.Write,
+ FileShare.None);
+
+ image.SaveAsPng(file);
+ });
+
+ _sawmill.Info($"Saved screenshot to {fullFileName}");
+ queued.Tcs.SetResult();
+ }
+ catch (Exception exc)
+ {
+ queued.Texture.Dispose();
+
+ if (!string.IsNullOrEmpty(exc.StackTrace))
+ _sawmill.Fatal(exc.StackTrace);
+
+ queued.Tcs.SetException(exc);
+ }
+ }
+ }
+ }
+}
diff --git a/Resources/Locale/en-US/administration/admin-verbs.ftl b/Resources/Locale/en-US/administration/admin-verbs.ftl
index 16715087ee..24294f0529 100644
--- a/Resources/Locale/en-US/administration/admin-verbs.ftl
+++ b/Resources/Locale/en-US/administration/admin-verbs.ftl
@@ -14,3 +14,5 @@ admin-verbs-erase-description = Removes the player from the round and crew manif
Players are shown a popup indicating them to play as if they never existed.
toolshed-verb-mark = Mark
toolshed-verb-mark-description = Places this entity into the $marked variable, a list of entities, replacing it's prior value.
+
+export-entity-verb-get-data-text = Export sprite
diff --git a/Resources/Locale/en-US/preferences/ui/humanoid-profile-editor.ftl b/Resources/Locale/en-US/preferences/ui/humanoid-profile-editor.ftl
index f75a21c5ff..04ea0d9d51 100644
--- a/Resources/Locale/en-US/preferences/ui/humanoid-profile-editor.ftl
+++ b/Resources/Locale/en-US/preferences/ui/humanoid-profile-editor.ftl
@@ -18,6 +18,8 @@ humanoid-profile-editor-pronouns-epicene-text = They / Them
humanoid-profile-editor-pronouns-neuter-text = It / It
humanoid-profile-editor-import-button = Import
humanoid-profile-editor-export-button = Export
+humanoid-profile-editor-export-image-button = Export image
+humanoid-profile-editor-open-image-button = Open images
humanoid-profile-editor-save-button = Save
humanoid-profile-editor-reset-button = Reset
humanoid-profile-editor-spawn-priority-label = Spawn priority: