diff --git a/Content.Client/DeadSpace/CharacterFlavor/HeadshotExamineWindow.xaml b/Content.Client/DeadSpace/CharacterFlavor/HeadshotExamineWindow.xaml
new file mode 100644
index 0000000000000..97c22edc2a135
--- /dev/null
+++ b/Content.Client/DeadSpace/CharacterFlavor/HeadshotExamineWindow.xaml
@@ -0,0 +1,58 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Content.Client/DeadSpace/CharacterFlavor/HeadshotExamineWindow.xaml.cs b/Content.Client/DeadSpace/CharacterFlavor/HeadshotExamineWindow.xaml.cs
new file mode 100644
index 0000000000000..ac3f18aa2dc99
--- /dev/null
+++ b/Content.Client/DeadSpace/CharacterFlavor/HeadshotExamineWindow.xaml.cs
@@ -0,0 +1,58 @@
+using Content.Client.UserInterface.Controls;
+using Robust.Client.AutoGenerated;
+using Robust.Client.Graphics;
+using Robust.Client.UserInterface.XAML;
+
+namespace Content.Client.DeadSpace.CharacterFlavor;
+
+[GenerateTypedNameReferences]
+public sealed partial class HeadshotExamineWindow : FancyWindow
+{
+ private readonly HeadshotUIController _controller;
+
+ public HeadshotExamineWindow(HeadshotUIController controller)
+ {
+ RobustXamlLoader.Load(this);
+ _controller = controller;
+ }
+
+ public void SetHeadshotTexture(OwnedTexture texture)
+ {
+ HeadshotLoadingLabel.Visible = false;
+ HeadshotTexture.Texture = texture;
+ HeadshotTexture.Visible = true;
+ HeadshotContainer.Visible = true;
+ }
+
+ public void SetFlavorText(string flavorText)
+ {
+ if (string.IsNullOrWhiteSpace(flavorText))
+ {
+ FlavorTextLabel.SetMessage(Loc.GetString("headshot-no-flavor-text"));
+ return;
+ }
+ FlavorTextLabel.Text = flavorText;
+ }
+
+ public void ShowLoading()
+ {
+ HeadshotContainer.Visible = true;
+ HeadshotTexture.Visible = false;
+ HeadshotLoadingLabel.Visible = true;
+ }
+
+ public void HideLoading()
+ {
+ HeadshotLoadingLabel.Visible = false;
+ }
+
+ protected override void Dispose(bool disposing)
+ {
+ base.Dispose(disposing);
+ if (disposing)
+ {
+ if (HeadshotTexture.Texture is OwnedTexture owned)
+ owned.Dispose();
+ }
+ }
+}
diff --git a/Content.Client/DeadSpace/CharacterFlavor/HeadshotPanel.xaml b/Content.Client/DeadSpace/CharacterFlavor/HeadshotPanel.xaml
new file mode 100644
index 0000000000000..a051dde2e9eb9
--- /dev/null
+++ b/Content.Client/DeadSpace/CharacterFlavor/HeadshotPanel.xaml
@@ -0,0 +1,93 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Content.Client/DeadSpace/CharacterFlavor/HeadshotPanel.xaml.cs b/Content.Client/DeadSpace/CharacterFlavor/HeadshotPanel.xaml.cs
new file mode 100644
index 0000000000000..e6c4d56341345
--- /dev/null
+++ b/Content.Client/DeadSpace/CharacterFlavor/HeadshotPanel.xaml.cs
@@ -0,0 +1,152 @@
+using System.IO;
+using Robust.Client.AutoGenerated;
+using Robust.Client.Graphics;
+using Robust.Client.UserInterface;
+using Robust.Client.UserInterface.XAML;
+using Robust.Shared.Utility;
+
+namespace Content.Client.DeadSpace.CharacterFlavor;
+
+[GenerateTypedNameReferences]
+public sealed partial class HeadshotPanel : Control
+{
+ public Action? OnHeadshotDataChanged;
+ public Action? OnUrlDownloadRequested;
+
+ private OwnedTexture? _previewTexture;
+
+ public HeadshotPanel()
+ {
+ RobustXamlLoader.Load(this);
+ IoCManager.InjectDependencies(this);
+
+ DownloadUrlButton.OnPressed += _ => OnDownloadUrl();
+ ApplyBase64Button.OnPressed += _ => OnApplyBase64();
+ ClearHeadshotButton.OnPressed += _ => OnClearHeadshot();
+ }
+
+ private void OnDownloadUrl()
+ {
+ var url = HeadshotUrlInput.Text.Trim();
+ if (string.IsNullOrWhiteSpace(url))
+ return;
+
+ if (!url.StartsWith("http://", StringComparison.OrdinalIgnoreCase) &&
+ !url.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
+ {
+ UrlStatusLabel.Text = Loc.GetString("headshot-invalid-url");
+ return;
+ }
+
+ UrlStatusLabel.Text = Loc.GetString("headshot-downloading");
+ OnUrlDownloadRequested?.Invoke(url);
+ }
+
+ private void OnApplyBase64()
+ {
+ var base64 = Rope.Collapse(HeadshotBase64Input.TextRope).Trim();
+ if (string.IsNullOrWhiteSpace(base64))
+ return;
+
+ try
+ {
+ Convert.FromBase64String(base64);
+ var dataUri = "data:image/png;base64," + base64;
+ SetPreviewFromBase64(base64);
+ OnHeadshotDataChanged?.Invoke(dataUri);
+ Base64StatusLabel.Text = Loc.GetString("headshot-base64-applied");
+ }
+ catch
+ {
+ Base64StatusLabel.Text = Loc.GetString("headshot-invalid-base64");
+ }
+ }
+
+ private void OnClearHeadshot()
+ {
+ ClearPreview();
+ OnHeadshotDataChanged?.Invoke(null);
+ HeadshotUrlInput.Text = string.Empty;
+ HeadshotBase64Input.TextRope = Rope.Leaf.Empty;
+ UrlStatusLabel.Text = string.Empty;
+ Base64StatusLabel.Text = string.Empty;
+ }
+
+ public void SetHeadshotData(string? headshotData)
+ {
+ if (string.IsNullOrWhiteSpace(headshotData))
+ {
+ ClearPreview();
+ return;
+ }
+
+ if (headshotData.StartsWith("data:image", StringComparison.OrdinalIgnoreCase))
+ {
+ var commaIndex = headshotData.IndexOf(',');
+ if (commaIndex >= 0 && commaIndex < headshotData.Length - 1)
+ {
+ var base64 = headshotData[(commaIndex + 1)..];
+ SetPreviewFromBase64(base64);
+ }
+ }
+ else if (headshotData.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
+ headshotData.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
+ {
+ HeadshotUrlInput.Text = headshotData;
+ }
+ }
+
+ public void SetPreviewFromBase64(string base64)
+ {
+ try
+ {
+ var bytes = Convert.FromBase64String(base64);
+ var clyde = IoCManager.Resolve();
+ using var stream = new MemoryStream(bytes);
+ var texture = clyde.LoadTextureFromPNGStream(stream, "headshot-preview");
+
+ _previewTexture?.Dispose();
+ _previewTexture = texture;
+
+ HeadshotPreview.Texture = texture;
+ HeadshotPreview.Visible = true;
+ NoHeadshotLabel.Visible = false;
+ }
+ catch
+ {
+ }
+ }
+
+ public void ClearPreview()
+ {
+ _previewTexture?.Dispose();
+ _previewTexture = null;
+ HeadshotPreview.Texture = null;
+ HeadshotPreview.Visible = false;
+ NoHeadshotLabel.Visible = true;
+ }
+
+ public void OnUrlDownloadCompleted(string? base64)
+ {
+ if (base64 != null)
+ {
+ SetPreviewFromBase64(base64);
+ var dataUri = "data:image/png;base64," + base64;
+ OnHeadshotDataChanged?.Invoke(dataUri);
+ UrlStatusLabel.Text = Loc.GetString("headshot-download-success");
+ }
+ else
+ {
+ UrlStatusLabel.Text = Loc.GetString("headshot-download-failed");
+ }
+ }
+
+ protected override void Dispose(bool disposing)
+ {
+ base.Dispose(disposing);
+ if (disposing)
+ {
+ _previewTexture?.Dispose();
+ }
+ }
+}
diff --git a/Content.Client/DeadSpace/CharacterFlavor/HeadshotSystem.cs b/Content.Client/DeadSpace/CharacterFlavor/HeadshotSystem.cs
new file mode 100644
index 0000000000000..6c34c091df494
--- /dev/null
+++ b/Content.Client/DeadSpace/CharacterFlavor/HeadshotSystem.cs
@@ -0,0 +1,45 @@
+using Content.Shared.DeadSpace.CharacterFlavor;
+using Robust.Client.Graphics;
+using Robust.Client.UserInterface;
+using Robust.Shared.Timing;
+
+namespace Content.Client.DeadSpace.CharacterFlavor;
+
+public sealed class HeadshotSystem : SharedHeadshotSystem
+{
+ [Dependency] private readonly IUserInterfaceManager _ui = default!;
+ [Dependency] private readonly IGameTiming _timing = default!;
+
+ public override void Initialize()
+ {
+ base.Initialize();
+ SubscribeNetworkEvent(OnHeadshotDownloadResult);
+ SubscribeNetworkEvent(OnHeadshotExamineResult);
+ }
+
+ protected override void OpenHeadshotFlavor(EntityUid actor, EntityUid target)
+ {
+ base.OpenHeadshotFlavor(actor, target);
+
+ if (!_timing.IsFirstTimePredicted)
+ return;
+
+ if (!HasComp(target))
+ return;
+
+ var controller = _ui.GetUIController();
+ controller.OpenExamineWindow(target);
+ }
+
+ private void OnHeadshotDownloadResult(HeadshotDownloadResultEvent ev)
+ {
+ var controller = _ui.GetUIController();
+ controller.OnHeadshotDownloadResult(ev);
+ }
+
+ private void OnHeadshotExamineResult(HeadshotExamineResultEvent ev)
+ {
+ var controller = _ui.GetUIController();
+ controller.OnHeadshotExamineResult(ev);
+ }
+}
diff --git a/Content.Client/DeadSpace/CharacterFlavor/HeadshotUIController.cs b/Content.Client/DeadSpace/CharacterFlavor/HeadshotUIController.cs
new file mode 100644
index 0000000000000..e882269d58610
--- /dev/null
+++ b/Content.Client/DeadSpace/CharacterFlavor/HeadshotUIController.cs
@@ -0,0 +1,128 @@
+using System.IO;
+using Content.Shared.DeadSpace.CharacterFlavor;
+using Robust.Client.Graphics;
+using Robust.Client.UserInterface;
+using Robust.Client.UserInterface.Controllers;
+using Robust.Shared.Network;
+
+namespace Content.Client.DeadSpace.CharacterFlavor;
+
+public sealed class HeadshotUIController : UIController
+{
+ [Dependency] private readonly IEntityNetworkManager _net = default!;
+ [Dependency] private readonly IClyde _clyde = default!;
+
+ private HeadshotExamineWindow? _examineWindow;
+ private Action? _pendingDownloadCallback;
+
+ public void OpenExamineWindow(EntityUid target)
+ {
+ _examineWindow?.Close();
+ _examineWindow = new HeadshotExamineWindow(this);
+
+ if (!EntityManager.TryGetComponent(target, out var headshot))
+ {
+ _examineWindow.Close();
+ _examineWindow = null;
+ return;
+ }
+
+ _examineWindow.SetFlavorText(headshot.FlavorText);
+ _examineWindow.OpenCentered();
+
+ if (string.IsNullOrWhiteSpace(headshot.HeadshotData))
+ return;
+
+ if (headshot.HeadshotData.StartsWith("data:image", StringComparison.OrdinalIgnoreCase))
+ {
+ var base64Data = headshot.HeadshotData;
+ var commaIndex = base64Data.IndexOf(',');
+ if (commaIndex >= 0 && commaIndex < base64Data.Length - 1)
+ {
+ base64Data = base64Data[(commaIndex + 1)..];
+ try
+ {
+ var bytes = Convert.FromBase64String(base64Data);
+ var texture = LoadTextureFromBytes(bytes);
+ if (texture != null)
+ _examineWindow.SetHeadshotTexture(texture);
+ }
+ catch
+ {
+ }
+ }
+ }
+ else if (headshot.HeadshotData.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
+ headshot.HeadshotData.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
+ {
+ _examineWindow.ShowLoading();
+ var netTarget = EntityManager.GetNetEntity(target);
+ _net.SendSystemNetworkMessage(new RequestHeadshotExamineEvent(netTarget));
+ }
+ }
+
+ public void OnHeadshotDownloadResult(HeadshotDownloadResultEvent ev)
+ {
+ if (_pendingDownloadCallback != null)
+ {
+ var callback = _pendingDownloadCallback;
+ _pendingDownloadCallback = null;
+ callback(ev.Success ? ev.Base64 : null);
+ }
+ }
+
+ public void OnHeadshotExamineResult(HeadshotExamineResultEvent ev)
+ {
+ if (_examineWindow is not { Disposed: false })
+ return;
+
+ if (ev.Image is { Length: > 0 })
+ {
+ var texture = LoadTextureFromBytes(ev.Image);
+ if (texture != null)
+ _examineWindow.SetHeadshotTexture(texture);
+ }
+ else
+ {
+ _examineWindow.HideLoading();
+ }
+ }
+
+ public OwnedTexture? LoadTextureFromBytes(byte[] imageBytes)
+ {
+ try
+ {
+ var stream = new MemoryStream(imageBytes);
+ return _clyde.LoadTextureFromPNGStream(stream, "headshot");
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ public OwnedTexture? LoadTextureFromBase64(string base64)
+ {
+ try
+ {
+ var bytes = Convert.FromBase64String(base64);
+ return LoadTextureFromBytes(bytes);
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ public void RequestUrlDownload(string url, Action onComplete)
+ {
+ _pendingDownloadCallback = onComplete;
+ _net.SendSystemNetworkMessage(new RequestHeadshotDownloadEvent(url));
+ }
+
+ public void CloseExamineWindow()
+ {
+ _examineWindow?.Close();
+ _examineWindow = null;
+ }
+}
diff --git a/Content.Client/Lobby/UI/HumanoidProfileEditor.xaml.cs b/Content.Client/Lobby/UI/HumanoidProfileEditor.xaml.cs
index f719669115931..a2a02a75ed281 100644
--- a/Content.Client/Lobby/UI/HumanoidProfileEditor.xaml.cs
+++ b/Content.Client/Lobby/UI/HumanoidProfileEditor.xaml.cs
@@ -8,6 +8,7 @@
using Content.Client.Players.PlayTimeTracking;
using Content.Client.Stylesheets;
using Content.Client.Sprite;
+using Content.Client.DeadSpace.CharacterFlavor;
using Content.Client.DeadSpace.UserInterface.Controls;
using Content.Client.UserInterface.Systems.Guidebook;
using Content.DeadSpace.Interfaces.Client;
@@ -65,6 +66,10 @@ public sealed partial class HumanoidProfileEditor : BoxContainer
private FlavorText.FlavorText? _flavorText;
private TextEdit? _flavorTextEdit;
+ // DS14-Start
+ private HeadshotPanel? _headshotPanel;
+ // DS14-End
+
// One at a time.
private LoadoutWindow? _loadoutWindow;
@@ -465,6 +470,7 @@ public HumanoidProfileEditor(
#endregion Markings
RefreshFlavorText();
+ RefreshHeadshotTab(); // DS14
#region Dummy
@@ -561,6 +567,49 @@ public void RefreshFlavorText()
}
}
+ // DS14-Start
+ ///
+ /// Refreshes the headshot editor status.
+ ///
+ public void RefreshHeadshotTab()
+ {
+ if (_headshotPanel == null)
+ {
+ _headshotPanel = new HeadshotPanel();
+ TabContainer.AddChild(_headshotPanel);
+ TabContainer.SetTabTitle(TabContainer.ChildCount - 1, Loc.GetString("headshot-panel-title"));
+
+ _headshotPanel.OnHeadshotDataChanged += OnHeadshotDataChange;
+ _headshotPanel.OnUrlDownloadRequested += OnHeadshotUrlDownload;
+ }
+
+ if (Profile != null)
+ _headshotPanel.SetHeadshotData(Profile.HeadshotData);
+
+ if (_readOnly && _headshotPanel != null)
+ SetInteractiveControlsDisabled(_headshotPanel, true);
+ }
+
+ private void OnHeadshotDataChange(string? data)
+ {
+ if (Profile is null || _readOnly)
+ return;
+
+ Profile = Profile.WithHeadshotData(data ?? string.Empty);
+ SetDirty();
+ }
+
+ private void OnHeadshotUrlDownload(string url)
+ {
+ var controller = UserInterfaceManager.GetUIController();
+ controller.RequestUrlDownload(url, base64 =>
+ {
+ if (_headshotPanel != null)
+ _headshotPanel.OnUrlDownloadCompleted(base64);
+ });
+ }
+ // DS14-End
+
///
/// Refreshes traits selector
///
@@ -900,6 +949,7 @@ public void SetProfile(HumanoidCharacterProfile? profile, int? slot, bool readOn
RefreshSpecies();
RefreshTraits();
RefreshFlavorText();
+ RefreshHeadshotTab(); // DS14
ReloadPreview();
if (Profile != null)
@@ -946,6 +996,10 @@ private void ApplyReadOnlyState()
SetInteractiveControlsDisabled(_flavorText, false);
if (_flavorTextEdit != null)
_flavorTextEdit.Editable = true;
+ // DS14-Start
+ if (_headshotPanel != null)
+ SetInteractiveControlsDisabled(_headshotPanel, false);
+ // DS14-End
UpdateSaveButton();
return;
}
@@ -984,6 +1038,10 @@ private void ApplyReadOnlyState()
_flavorTextEdit.Editable = false;
if (_flavorText != null)
SetInteractiveControlsDisabled(_flavorText, true);
+ // DS14-Start
+ if (_headshotPanel != null)
+ SetInteractiveControlsDisabled(_headshotPanel, true);
+ // DS14-End
_loadoutWindow?.Dispose();
_loadoutWindow = null;
}
@@ -1388,6 +1446,13 @@ protected override void Dispose(bool disposing)
_loadoutWindow?.Dispose();
_loadoutWindow = null;
+ // DS14-Start
+ if (_headshotPanel != null)
+ {
+ _headshotPanel.Dispose();
+ _headshotPanel = null;
+ }
+ // DS14-End
}
protected override void EnteredTree()
diff --git a/Content.Server/DeadSpace/CharacterFlavor/HeadshotSystem.cs b/Content.Server/DeadSpace/CharacterFlavor/HeadshotSystem.cs
new file mode 100644
index 0000000000000..63e2d80cf51f2
--- /dev/null
+++ b/Content.Server/DeadSpace/CharacterFlavor/HeadshotSystem.cs
@@ -0,0 +1,121 @@
+using System.IO;
+using System.Net.Http;
+using System.Threading.Tasks;
+using Content.Shared.DeadSpace.CharacterFlavor;
+using Robust.Shared.Player;
+
+namespace Content.Server.DeadSpace.CharacterFlavor;
+
+public sealed class HeadshotSystem : SharedHeadshotSystem
+{
+ private static readonly HttpClient HttpClient = new();
+
+ public override void Initialize()
+ {
+ base.Initialize();
+ HttpClient.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0");
+ SubscribeNetworkEvent(OnRequestHeadshotDownload);
+ SubscribeNetworkEvent(OnRequestHeadshotExamine);
+ }
+
+ protected override async void OpenHeadshotFlavor(EntityUid actor, EntityUid target)
+ {
+ base.OpenHeadshotFlavor(actor, target);
+
+ if (!TryComp(target, out var headshot))
+ return;
+
+ if (string.IsNullOrWhiteSpace(headshot.HeadshotData))
+ return;
+
+ if (!headshot.HeadshotData.StartsWith("http://", StringComparison.OrdinalIgnoreCase) &&
+ !headshot.HeadshotData.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
+ return;
+
+ var image = await DownloadImageAsync(headshot.HeadshotData);
+ var ev = new HeadshotExamineResultEvent(GetNetEntity(target), image, headshot.FlavorText);
+ RaiseNetworkEvent(ev, actor);
+ }
+
+ private async void OnRequestHeadshotDownload(RequestHeadshotDownloadEvent ev, EntitySessionEventArgs args)
+ {
+ if (!IsValidHeadshotUrl(ev.Url))
+ {
+ RaiseNetworkEvent(new HeadshotDownloadResultEvent(null, false), Filter.SinglePlayer(args.SenderSession));
+ return;
+ }
+
+ var imageBytes = await DownloadImageAsync(ev.Url);
+ if (imageBytes == null)
+ {
+ RaiseNetworkEvent(new HeadshotDownloadResultEvent(null, false), Filter.SinglePlayer(args.SenderSession));
+ return;
+ }
+
+ var base64 = Convert.ToBase64String(imageBytes);
+ RaiseNetworkEvent(new HeadshotDownloadResultEvent(base64, true), Filter.SinglePlayer(args.SenderSession));
+ }
+
+ private async void OnRequestHeadshotExamine(RequestHeadshotExamineEvent ev, EntitySessionEventArgs args)
+ {
+ var target = GetEntity(ev.Target);
+ if (!TryComp(target, out var headshot))
+ return;
+
+ if (string.IsNullOrWhiteSpace(headshot.HeadshotData))
+ return;
+
+ if (!headshot.HeadshotData.StartsWith("http://", StringComparison.OrdinalIgnoreCase) &&
+ !headshot.HeadshotData.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
+ return;
+
+ var image = await DownloadImageAsync(headshot.HeadshotData);
+ var result = new HeadshotExamineResultEvent(ev.Target, image, headshot.FlavorText);
+ RaiseNetworkEvent(result, Filter.SinglePlayer(args.SenderSession));
+ }
+
+ private static async Task DownloadImageAsync(string url)
+ {
+ try
+ {
+ using var response = await HttpClient.GetAsync(url, HttpCompletionOption.ResponseHeadersRead);
+ if (!response.IsSuccessStatusCode)
+ return null;
+
+ const int maxSize = 5 * 1024 * 1024;
+ await using var stream = await response.Content.ReadAsStreamAsync();
+ using var ms = new MemoryStream();
+ var buffer = new byte[8192];
+ int totalRead = 0;
+ while (true)
+ {
+ var read = await stream.ReadAsync(buffer);
+ if (read == 0)
+ break;
+ totalRead += read;
+ if (totalRead > maxSize)
+ return null;
+ ms.Write(buffer, 0, read);
+ }
+ return ms.ToArray();
+ }
+ catch (Exception ex)
+ {
+ Logger.Error($"Failed to download image from {url}: {ex}");
+ return null;
+ }
+ }
+
+ private static bool IsValidHeadshotUrl(string url)
+ {
+ if (string.IsNullOrWhiteSpace(url))
+ return false;
+ if (url.Length > 1000)
+ return false;
+ if (!Uri.TryCreate(url, UriKind.Absolute, out var uri))
+ return false;
+ if (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)
+ return false;
+ return true;
+ }
+}
diff --git a/Content.Server/Station/Systems/StationSpawningSystem.cs b/Content.Server/Station/Systems/StationSpawningSystem.cs
index ba9487b031f5e..185e2ba020e63 100644
--- a/Content.Server/Station/Systems/StationSpawningSystem.cs
+++ b/Content.Server/Station/Systems/StationSpawningSystem.cs
@@ -7,6 +7,7 @@
using Content.Shared.Access.Systems;
using Content.Shared.CCVar;
using Content.Shared.Clothing;
+using Content.Shared.DeadSpace.CharacterFlavor; // DS14
using Content.Shared.DetailExaminable;
using Content.Shared.Humanoid;
using Content.Shared.Humanoid.Prototypes;
@@ -140,6 +141,15 @@ public EntityUid SpawnPlayerMob(
{
AddComp(entity.Value).Content = profile.FlavorText;
}
+
+ // DS14-Start
+ if (!string.IsNullOrEmpty(profile.HeadshotData))
+ {
+ var headshot = AddComp(entity.Value);
+ headshot.HeadshotData = profile.HeadshotData;
+ headshot.FlavorText = profile.FlavorText;
+ }
+ // DS14-End
}
if (loadout != null)
diff --git a/Content.Shared/CCVar/CCVars.Ic.cs b/Content.Shared/CCVar/CCVars.Ic.cs
index a075bb0144609..2bb18eb11745c 100644
--- a/Content.Shared/CCVar/CCVars.Ic.cs
+++ b/Content.Shared/CCVar/CCVars.Ic.cs
@@ -26,7 +26,7 @@ public sealed partial class CCVars
/// Allows flavor text (character descriptions).
///
public static readonly CVarDef FlavorText =
- CVarDef.Create("ic.flavor_text", false, CVar.SERVER | CVar.REPLICATED);
+ CVarDef.Create("ic.flavor_text", true, CVar.SERVER | CVar.REPLICATED);
///
/// Sets the maximum length for flavor text (character descriptions).
diff --git a/Content.Shared/DeadSpace/CharacterFlavor/HeadshotComponent.cs b/Content.Shared/DeadSpace/CharacterFlavor/HeadshotComponent.cs
new file mode 100644
index 0000000000000..c8c89bf33aa11
--- /dev/null
+++ b/Content.Shared/DeadSpace/CharacterFlavor/HeadshotComponent.cs
@@ -0,0 +1,14 @@
+using Robust.Shared.GameStates;
+using Robust.Shared.Serialization;
+
+namespace Content.Shared.DeadSpace.CharacterFlavor;
+
+[RegisterComponent, NetworkedComponent, AutoGenerateComponentState]
+public sealed partial class HeadshotComponent : Component
+{
+ [AutoNetworkedField]
+ public string HeadshotData = string.Empty;
+
+ [AutoNetworkedField]
+ public string FlavorText = string.Empty;
+}
diff --git a/Content.Shared/DeadSpace/CharacterFlavor/HeadshotMessages.cs b/Content.Shared/DeadSpace/CharacterFlavor/HeadshotMessages.cs
new file mode 100644
index 0000000000000..c78341c8ed986
--- /dev/null
+++ b/Content.Shared/DeadSpace/CharacterFlavor/HeadshotMessages.cs
@@ -0,0 +1,49 @@
+using Robust.Shared.Serialization;
+
+namespace Content.Shared.DeadSpace.CharacterFlavor;
+
+[Serializable, NetSerializable]
+public sealed partial class RequestHeadshotDownloadEvent : EntityEventArgs
+{
+ public readonly string Url;
+ public RequestHeadshotDownloadEvent(string url)
+ {
+ Url = url;
+ }
+}
+
+[Serializable, NetSerializable]
+public sealed partial class HeadshotDownloadResultEvent : EntityEventArgs
+{
+ public readonly string? Base64;
+ public readonly bool Success;
+ public HeadshotDownloadResultEvent(string? base64, bool success)
+ {
+ Base64 = base64;
+ Success = success;
+ }
+}
+
+[Serializable, NetSerializable]
+public sealed partial class RequestHeadshotExamineEvent : EntityEventArgs
+{
+ public readonly NetEntity Target;
+ public RequestHeadshotExamineEvent(NetEntity target)
+ {
+ Target = target;
+ }
+}
+
+[Serializable, NetSerializable]
+public sealed partial class HeadshotExamineResultEvent : EntityEventArgs
+{
+ public readonly NetEntity Target;
+ public readonly byte[]? Image;
+ public readonly string FlavorText;
+ public HeadshotExamineResultEvent(NetEntity target, byte[]? image, string flavorText)
+ {
+ Target = target;
+ Image = image;
+ FlavorText = flavorText;
+ }
+}
diff --git a/Content.Shared/DeadSpace/CharacterFlavor/SharedHeadshotSystem.cs b/Content.Shared/DeadSpace/CharacterFlavor/SharedHeadshotSystem.cs
new file mode 100644
index 0000000000000..b716d53ffc908
--- /dev/null
+++ b/Content.Shared/DeadSpace/CharacterFlavor/SharedHeadshotSystem.cs
@@ -0,0 +1,42 @@
+using Content.Shared.Examine;
+using Content.Shared.IdentityManagement;
+using Content.Shared.Verbs;
+using Robust.Shared.Utility;
+
+namespace Content.Shared.DeadSpace.CharacterFlavor;
+
+public abstract class SharedHeadshotSystem : EntitySystem
+{
+ [Dependency] private readonly ExamineSystemShared _examine = default!;
+
+ public override void Initialize()
+ {
+ base.Initialize();
+ SubscribeLocalEvent>(OnGetExamineVerbs);
+ }
+
+ private void OnGetExamineVerbs(Entity ent, ref GetVerbsEvent args)
+ {
+ if (Identity.Name(args.Target, EntityManager) != MetaData(args.Target).EntityName)
+ return;
+
+ var detailsRange = _examine.IsInDetailsRange(args.User, ent);
+ var user = args.User;
+
+ var verb = new ExamineVerb
+ {
+ Act = () => OpenHeadshotFlavor(user, ent.Owner),
+ Text = Loc.GetString("detail-examinable-verb-text"),
+ Category = VerbCategory.Examine,
+ Disabled = !detailsRange,
+ Message = detailsRange ? null : Loc.GetString("detail-examinable-verb-disabled"),
+ Icon = new SpriteSpecifier.Texture(new("/Textures/Interface/VerbIcons/examine.svg.192dpi.png"))
+ };
+
+ args.Verbs.Add(verb);
+ }
+
+ protected virtual void OpenHeadshotFlavor(EntityUid actor, EntityUid target)
+ {
+ }
+}
diff --git a/Content.Shared/Preferences/HumanoidCharacterProfile.cs b/Content.Shared/Preferences/HumanoidCharacterProfile.cs
index 542b9342c0adf..61dffd8cbe8e7 100644
--- a/Content.Shared/Preferences/HumanoidCharacterProfile.cs
+++ b/Content.Shared/Preferences/HumanoidCharacterProfile.cs
@@ -69,6 +69,14 @@ public sealed partial class HumanoidCharacterProfile : ICharacterProfile
[DataField]
public string FlavorText { get; set; } = string.Empty;
+ // DS14-Start
+ ///
+ /// Headshot image data (base64 data URI or Pinterest URL).
+ ///
+ [DataField]
+ public string HeadshotData { get; set; } = string.Empty;
+ // DS14-End
+
///
/// Associated for this profile.
///
@@ -143,7 +151,8 @@ public HumanoidCharacterProfile(
PreferenceUnavailableMode preferenceUnavailable,
HashSet> antagPreferences,
HashSet> traitPreferences,
- Dictionary loadouts)
+ Dictionary loadouts,
+ string headshotData = "") // DS14
{
Name = name;
FlavorText = flavortext;
@@ -159,6 +168,7 @@ public HumanoidCharacterProfile(
_antagPreferences = antagPreferences;
_traitPreferences = traitPreferences;
_loadouts = loadouts;
+ HeadshotData = headshotData; // DS14
var hasHighPrority = false;
foreach (var (key, value) in _jobPriorities)
@@ -190,7 +200,8 @@ public HumanoidCharacterProfile(HumanoidCharacterProfile other)
other.PreferenceUnavailable,
new HashSet>(other.AntagPreferences),
new HashSet>(other.TraitPreferences),
- new Dictionary(other.Loadouts))
+ new Dictionary(other.Loadouts),
+ other.HeadshotData) // DS14
{
}
@@ -292,6 +303,13 @@ public HumanoidCharacterProfile WithFlavorText(string flavorText)
return new(this) { FlavorText = flavorText };
}
+ // DS14-Start
+ public HumanoidCharacterProfile WithHeadshotData(string headshotData)
+ {
+ return new(this) { HeadshotData = headshotData };
+ }
+ // DS14-End
+
public HumanoidCharacterProfile WithAge(int age)
{
return new(this) { Age = age };
@@ -495,6 +513,9 @@ public bool MemberwiseEquals(ICharacterProfile maybeOther)
if (!_traitPreferences.SequenceEqual(other._traitPreferences)) return false;
if (!Loadouts.SequenceEqual(other.Loadouts)) return false;
if (FlavorText != other.FlavorText) return false;
+ // DS14-Start
+ if (HeadshotData != other.HeadshotData) return false;
+ // DS14-End
return Appearance.MemberwiseEquals(other.Appearance);
}
@@ -584,6 +605,14 @@ public void EnsureValid(ICommonSession session, IDependencyCollection collection
flavortext = FormattedMessage.RemoveMarkupOrThrow(FlavorText);
}
+ // DS14-Start
+ // Validate headshot data - max 2MB base64 string
+ if (HeadshotData.Length > 2 * 1024 * 1024)
+ {
+ HeadshotData = string.Empty;
+ }
+ // DS14-End
+
var appearance = HumanoidCharacterAppearance.EnsureValid(Appearance, Species, Sex, sponsorMarkings); // DS14-sponsors
var prefsUnavailableMode = PreferenceUnavailable switch
@@ -772,6 +801,7 @@ public override int GetHashCode()
hashCode.Add(_loadouts);
hashCode.Add(Name);
hashCode.Add(FlavorText);
+ hashCode.Add(HeadshotData); // DS14
hashCode.Add(Species);
hashCode.Add(Age);
hashCode.Add((int)Sex);
diff --git a/Resources/Locale/en-US/deadspace/characterflavor/headshot.ftl b/Resources/Locale/en-US/deadspace/characterflavor/headshot.ftl
new file mode 100644
index 0000000000000..f991ef761669e
--- /dev/null
+++ b/Resources/Locale/en-US/deadspace/characterflavor/headshot.ftl
@@ -0,0 +1,19 @@
+headshot-panel-title = Headshot
+headshot-url-label = Image URL (Pinterest)
+headshot-url-placeholder = Paste Pinterest image URL...
+headshot-download-button = Download
+headshot-base64-label = Base64 Image
+headshot-apply-base64-button = Apply
+headshot-preview-label = Preview
+headshot-no-image = No headshot set
+headshot-clear-button = Clear headshot
+headshot-invalid-url = Invalid URL
+headshot-downloading = Downloading...
+headshot-download-success = Downloaded!
+headshot-download-failed = Download failed
+headshot-base64-applied = Base64 applied
+headshot-invalid-base64 = Invalid Base64
+headshot-examine-title = Character Examination
+headshot-flavor-text-label = Description
+headshot-loading = Loading image...
+headshot-no-flavor-text = No description
diff --git a/Resources/Locale/ru-RU/deadspace/characterflavor/headshot.ftl b/Resources/Locale/ru-RU/deadspace/characterflavor/headshot.ftl
new file mode 100644
index 0000000000000..77dcea5673951
--- /dev/null
+++ b/Resources/Locale/ru-RU/deadspace/characterflavor/headshot.ftl
@@ -0,0 +1,19 @@
+headshot-panel-title = Хэдшот
+headshot-url-label = Ссылка на изображение (Pinterest)
+headshot-url-placeholder = Вставьте ссылку на изображение с Pinterest...
+headshot-download-button = Загрузить
+headshot-base64-label = Base64 изображение
+headshot-apply-base64-button = Применить
+headshot-preview-label = Предпросмотр
+headshot-no-image = Хэдшот не установлен
+headshot-clear-button = Очистить хэдшот
+headshot-invalid-url = Неверная ссылка
+headshot-downloading = Загрузка...
+headshot-download-success = Загружено!
+headshot-download-failed = Ошибка загрузки
+headshot-base64-applied = Base64 применён
+headshot-invalid-base64 = Неверный Base64
+headshot-examine-title = Осмотр персонажа
+headshot-flavor-text-label = Описание
+headshot-loading = Загрузка изображения...
+headshot-no-flavor-text = Описание отсутствует