diff --git a/Content.Client/Decals/DecalPlacementSystem.cs b/Content.Client/Decals/DecalPlacementSystem.cs
index ac39182ac68..441ae23b905 100644
--- a/Content.Client/Decals/DecalPlacementSystem.cs
+++ b/Content.Client/Decals/DecalPlacementSystem.cs
@@ -3,11 +3,14 @@
using Content.Client.Decals.Overlays;
using Content.Shared.Actions;
using Content.Shared.Decals;
+using Content.Shared.Input; // Exodus
using Robust.Client.GameObjects;
using Robust.Client.Graphics;
using Robust.Client.Input;
using Robust.Shared.Input;
using Robust.Shared.Input.Binding;
+using Robust.Shared.Map; // Exodus
+using Robust.Shared.Player; // Exodus
using Robust.Shared.Prototypes;
namespace Content.Client.Decals;
@@ -17,6 +20,7 @@ namespace Content.Client.Decals;
public sealed partial class DecalPlacementSystem : EntitySystem
{
[Dependency] private IInputManager _inputManager = default!;
+ [Dependency] private IMapManager _mapManager = default!; // Exodus
[Dependency] private IOverlayManager _overlay = default!;
[Dependency] private IPrototypeManager _protoMan = default!;
[Dependency] private InputSystem _inputSystem = default!;
@@ -35,6 +39,48 @@ public sealed partial class DecalPlacementSystem : EntitySystem
private bool _placing;
private bool _erasing;
+ // Exodus-Start
+ private bool _eyedropper;
+
+ ///
+ /// Whether the eyedropper is currently selected.
+ ///
+ public bool EyedropperActive => _eyedropper;
+
+ ///
+ /// Raised when the eyedropper successfully copies a color from a decal.
+ ///
+ public event Action? EyedropperPicked;
+
+ public void SetEyedropper(bool active)
+ {
+ _eyedropper = active && _active;
+
+ // The eyedropper and a paste (in copypaste mode) are mutually exclusive.
+ if (_eyedropper)
+ {
+ _stamping = false;
+ _stamp.Clear();
+ }
+ }
+
+ // Copy decal(s) under the cursor, only while the window is open.
+ private readonly List<(Vector2 Offset, Decal Decal)> _stamp = new();
+ private bool _stamping;
+
+ ///
+ /// Whether a multi-decal stamp is currently held and ready to be placed.
+ ///
+ public bool Stamping => _stamping && _stamp.Count > 0;
+
+ public IReadOnlyList<(Vector2 Offset, Decal Decal)> Stamp => _stamp;
+
+ ///
+ /// Raised when a single decal is copied from the map, so the window can mirror its settings.
+ ///
+ public event Action? DecalCopied;
+ // Exodus-End
+
public (DecalPrototype? Decal, bool Snap, Angle Angle, Color Color) GetActiveDecal()
{
return _active && _decalId != null ?
@@ -50,6 +96,23 @@ public override void Initialize()
CommandBinds.Builder.Bind(EngineKeyFunctions.EditorPlaceObject, new PointerStateInputCmdHandler(
(session, coords, uid) =>
{
+ // Exodus-Start: left click while the eyedropper is active copies a color.
+ if (_eyedropper)
+ {
+ _eyedropper = false;
+
+ if (TryPickDecalColor(coords, out var picked))
+ EyedropperPicked?.Invoke(picked);
+
+ return true;
+ }
+ if (_stamping)
+ {
+ PlaceStamp(coords);
+ return true;
+ }
+ // Exodus-End
+
if (!_active || _placing || _decalId == null)
return false;
@@ -85,6 +148,21 @@ public override void Initialize()
.Bind(EngineKeyFunctions.EditorCancelPlace, new PointerStateInputCmdHandler(
(session, coords, uid) =>
{
+ // Exodus-Start: right click cancels the eyedropper or a held stamp instead of erasing.
+ if (_eyedropper)
+ {
+ _eyedropper = false;
+ return true;
+ }
+
+ if (_stamping)
+ {
+ _stamping = false;
+ _stamp.Clear();
+ return true;
+ }
+ // Exodus-End
+
if (!_active || _erasing)
return false;
@@ -100,7 +178,12 @@ public override void Initialize()
_erasing = false;
return true;
- }, true)).Register();
+ }, true))
+ // Exodus-Start: copy the decal (O - not 0) or the whole stack (Ctrl+O) under the cursor.
+ .Bind(ContentKeyFunctions.EditorCopyDecal, new PointerInputCmdHandler(OnCopyDecal))
+ .Bind(ContentKeyFunctions.EditorCopyDecalStack, new PointerInputCmdHandler(OnCopyDecalStack))
+ // Exodus-End
+ .Register();
SubscribeLocalEvent(OnFillSlot);
SubscribeLocalEvent(OnPlaceDecalAction);
@@ -189,12 +272,144 @@ public void UpdateDecalInfo(string id, Color color, float rotation, bool snap, i
_cleanable = cleanable;
}
+ // Exodus-Start: clear the active decal. This wast here for years, really?!
+ public void ClearDecal()
+ {
+ _decalId = null;
+ }
+ // Exodus-End
+
public void SetActive(bool active)
{
_active = active;
+ // Exodus-Start: arming/holding a tool is always an explicit user action.
+ _eyedropper = false;
+ _stamping = false;
+ _stamp.Clear();
+ // Exodus-End
if (_active)
_inputManager.Contexts.SetActiveContext("editor");
else
_inputSystem.SetEntityContextActive();
}
+
+ // Exodus-Start
+ /// "O": copy the top decal under the cursor into the placer.
+ private bool OnCopyDecal(ICommonSession? session, EntityCoordinates coords, EntityUid uid)
+ {
+ if (!_active)
+ return false;
+
+ _eyedropper = false;
+ _stamping = false;
+ _stamp.Clear();
+
+ if (TryGetDecalsUnder(coords, out _, out var decals) && Topmost(decals) is { } top)
+ DecalCopied?.Invoke(top.Decal);
+
+ return true;
+ }
+
+ /// "Ctrl+O": copy every decal under the cursor as a stamp to place as a group.
+ private bool OnCopyDecalStack(ICommonSession? session, EntityCoordinates coords, EntityUid uid)
+ {
+ if (!_active)
+ return false;
+
+ _eyedropper = false;
+ _stamp.Clear();
+ _stamping = false;
+
+ if (!TryGetDecalsUnder(coords, out var localPos, out var decals))
+ return true;
+
+ // So stamp stays tile-aligned when placed.
+ var origin = localPos.Floored();
+ foreach (var (_, decal) in decals)
+ _stamp.Add((decal.Coordinates - origin, decal));
+
+ _stamping = true;
+ return true;
+ }
+
+ private void PlaceStamp(EntityCoordinates coords)
+ {
+ if (!TryGetGridLocal(coords, out var gridUid, out var localPos))
+ return;
+
+ var origin = localPos.Floored();
+ foreach (var (offset, decal) in _stamp)
+ {
+ var newPos = origin + offset;
+ var target = new EntityCoordinates(gridUid, newPos);
+ if (!target.IsValid(EntityManager))
+ continue;
+
+ var copy = new Decal(newPos, decal.Id, decal.Color, decal.Angle, decal.ZIndex, decal.Cleanable);
+ RaiseNetworkEvent(new RequestDecalPlacementEvent(copy, GetNetCoordinates(target)));
+ }
+ }
+
+ private bool TryPickDecalColor(EntityCoordinates coords, out Color color)
+ {
+ color = Color.White;
+ if (!TryGetDecalsUnder(coords, out _, out var decals) || Topmost(decals) is not { } top)
+ return false;
+
+ color = top.Decal.Color ?? Color.White;
+ return true;
+ }
+
+ /// Resolve the grid and grid-local position under the given coordinates.
+ private bool TryGetGridLocal(EntityCoordinates coords, out EntityUid gridUid, out Vector2 localPos)
+ {
+ localPos = default;
+ var mapPos = _transform.ToMapCoordinates(coords);
+ if (!_mapManager.TryFindGridAt(mapPos, out gridUid, out _))
+ return false;
+
+ localPos = Vector2.Transform(mapPos.Position, _transform.GetInvWorldMatrix(gridUid));
+ return true;
+ }
+
+ /// Collect every decal whose 1x1 footprint contains the cursor.
+ private bool TryGetDecalsUnder(EntityCoordinates coords, out Vector2 localPos, out List<(uint Index, Decal Decal)> decals)
+ {
+ decals = new();
+ if (!TryGetGridLocal(coords, out var gridUid, out localPos)
+ || !TryComp(gridUid, out var decalGrid))
+ return false;
+
+ var chunkIndices = SharedDecalSystem.GetChunkIndices(localPos);
+ if (!decalGrid.ChunkCollection.ChunkCollection.TryGetValue(chunkIndices, out var chunk))
+ return false;
+
+ foreach (var (id, decal) in chunk.Decals)
+ {
+ // Decals are drawn as a 1x1 tile with their bottom-left corner at Coordinates.
+ if (localPos.X < decal.Coordinates.X || localPos.X >= decal.Coordinates.X + 1f ||
+ localPos.Y < decal.Coordinates.Y || localPos.Y >= decal.Coordinates.Y + 1f)
+ continue;
+
+ decals.Add((id, decal));
+ }
+
+ return decals.Count > 0;
+ }
+
+ /// Pick the decal drawn on top: highest ZIndex, then highest id (matches the overlay).
+ private static (uint Index, Decal Decal)? Topmost(List<(uint Index, Decal Decal)> decals)
+ {
+ (uint Index, Decal Decal)? best = null;
+ foreach (var entry in decals)
+ {
+ if (best is not { } b
+ || entry.Decal.ZIndex > b.Decal.ZIndex
+ || (entry.Decal.ZIndex == b.Decal.ZIndex && entry.Index > b.Index))
+ best = entry;
+ }
+
+ return best;
+ }
+ // Exodus-End
}
diff --git a/Content.Client/Decals/Overlays/DecalPlacementOverlay.cs b/Content.Client/Decals/Overlays/DecalPlacementOverlay.cs
index e0e73af5f79..16b88fd8da9 100644
--- a/Content.Client/Decals/Overlays/DecalPlacementOverlay.cs
+++ b/Content.Client/Decals/Overlays/DecalPlacementOverlay.cs
@@ -1,4 +1,5 @@
using System.Numerics;
+using Content.Shared.Decals; // Exodus
using Robust.Client.GameObjects;
using Robust.Client.Graphics;
using Robust.Client.Input;
@@ -13,6 +14,7 @@ public sealed partial class DecalPlacementOverlay : Overlay
[Dependency] private IEyeManager _eyeManager = default!;
[Dependency] private IInputManager _inputManager = default!;
[Dependency] private IMapManager _mapManager = default!;
+ [Dependency] private IPrototypeManager _protoMan = default!; // Exodus
private readonly DecalPlacementSystem _placement;
private readonly SharedTransformSystem _transform;
private readonly SpriteSystem _sprite;
@@ -30,6 +32,20 @@ public DecalPlacementOverlay(DecalPlacementSystem placement, SharedTransformSyst
protected override void Draw(in OverlayDrawArgs args)
{
+ // Exodus-Start: while a tool is active, draw its preview instead of the single-decal preview.
+ if (_placement.EyedropperActive)
+ {
+ DrawEyedropper(args);
+ return;
+ }
+
+ if (_placement.Stamping)
+ {
+ DrawStamp(args);
+ return;
+ }
+ // Exodus-End
+
var (decal, snap, rotation, color) = _placement.GetActiveDecal();
if (decal == null)
@@ -67,4 +83,70 @@ protected override void Draw(in OverlayDrawArgs args)
handle.DrawTextureRect(_sprite.Frame0(decal.Sprite), box, color);
handle.SetTransform(Matrix3x2.Identity);
}
+
+ // Exodus-Start: preview of the copied decal stack, following the cursor before it is stamped.
+ private void DrawStamp(in OverlayDrawArgs args)
+ {
+ var mouseScreenPos = _inputManager.MouseScreenPosition;
+ var mousePos = _eyeManager.PixelToMap(mouseScreenPos);
+
+ if (mousePos.MapId != args.MapId)
+ return;
+
+ if (!_mapManager.TryFindGridAt(mousePos, out var gridUid, out _))
+ return;
+
+ var handle = args.WorldHandle;
+ handle.SetTransform(_transform.GetWorldMatrix(gridUid));
+
+ var localPos = Vector2.Transform(mousePos.Position, _transform.GetInvWorldMatrix(gridUid));
+ var origin = localPos.Floored();
+
+ foreach (var (offset, decal) in _placement.Stamp)
+ {
+ if (!_protoMan.TryIndex(decal.Id, out var proto))
+ continue;
+
+ var texture = _sprite.Frame0(proto.Sprite);
+ var pos = origin + offset;
+ var color = decal.Color ?? Color.White;
+
+ if (decal.Angle == Angle.Zero)
+ handle.DrawTexture(texture, pos, color);
+ else
+ handle.DrawTexture(texture, pos, decal.Angle, color);
+ }
+
+ handle.SetTransform(Matrix3x2.Identity);
+ }
+
+ // crosshair shown at the cursor marking the sample point of the eyedropper tool.
+ private void DrawEyedropper(in OverlayDrawArgs args)
+ {
+ var mouseScreenPos = _inputManager.MouseScreenPosition;
+ var mousePos = _eyeManager.PixelToMap(mouseScreenPos);
+
+ if (mousePos.MapId != args.MapId)
+ return;
+
+ // The handle is already in world space here (we never set a transform), so draw directly.
+ var handle = args.WorldHandle;
+ var pos = mousePos.Position;
+ const float arm = 0.35f;
+ const float gap = 0.08f;
+
+ // Draw a dark outline first so the crosshair stays visible over any decal color.
+ DrawCross(handle, pos, arm, gap, 0.04f, Color.Black.WithAlpha(0.6f));
+ DrawCross(handle, pos, arm, gap, 0f, Color.White);
+ handle.DrawCircle(pos, gap, Color.White, false);
+ }
+
+ private static void DrawCross(DrawingHandleWorld handle, Vector2 pos, float arm, float gap, float pad, Color color)
+ {
+ handle.DrawLine(pos + new Vector2(-arm - pad, 0f), pos + new Vector2(-gap + pad, 0f), color);
+ handle.DrawLine(pos + new Vector2(gap - pad, 0f), pos + new Vector2(arm + pad, 0f), color);
+ handle.DrawLine(pos + new Vector2(0f, -arm - pad), pos + new Vector2(0f, -gap + pad), color);
+ handle.DrawLine(pos + new Vector2(0f, gap - pad), pos + new Vector2(0f, arm + pad), color);
+ }
+ // Exodus-End
}
diff --git a/Content.Client/Decals/UI/DecalPlacerWindow.xaml b/Content.Client/Decals/UI/DecalPlacerWindow.xaml
index 0a4f2d67a6b..7362b533393 100644
--- a/Content.Client/Decals/UI/DecalPlacerWindow.xaml
+++ b/Content.Client/Decals/UI/DecalPlacerWindow.xaml
@@ -12,7 +12,16 @@
+
+
+
+
+
+
+
+
diff --git a/Content.Client/Decals/UI/DecalPlacerWindow.xaml.cs b/Content.Client/Decals/UI/DecalPlacerWindow.xaml.cs
index 1625cb69a3b..d526f300cc9 100644
--- a/Content.Client/Decals/UI/DecalPlacerWindow.xaml.cs
+++ b/Content.Client/Decals/UI/DecalPlacerWindow.xaml.cs
@@ -1,4 +1,5 @@
using System.Linq;
+using Content.Client._Exodus.Decals; // Exodus
using Content.Client.Stylesheets;
using Content.Shared.Decals;
using Robust.Client.AutoGenerated;
@@ -19,6 +20,7 @@ public sealed partial class DecalPlacerWindow : DefaultWindow
[Dependency] private IEntityManager _e = default!;
private readonly DecalPlacementSystem _decalPlacementSystem;
+ private readonly FavoriteDecalColorsSystem _favorites; // Exodus
public FloatSpinBox RotationSpinBox;
@@ -41,6 +43,7 @@ public DecalPlacerWindow()
IoCManager.InjectDependencies(this);
_decalPlacementSystem = _e.System();
+ _favorites = _e.System(); // Exodus
// This needs to be done in C# so we can have custom stuff passed in the constructor
// and thus have a proper step size
@@ -53,6 +56,23 @@ public DecalPlacerWindow()
Search.OnTextChanged += _ => RefreshList();
ColorPicker.OnColorChanged += OnColorPicked;
+ // Exodus-Start
+ // Arm the eyedropper. Subscribe for this window instance's lifetime (unsubscribed in Dispose);
+ // tying it to Opened/Close leaks a stale handler when the window is disposed without closing.
+ EyedropperButton.OnPressed += _ => _decalPlacementSystem.SetEyedropper(!_decalPlacementSystem.EyedropperActive);
+ _decalPlacementSystem.EyedropperPicked += OnEyedropperPicked;
+ _decalPlacementSystem.DecalCopied += OnDecalCopied; // "O" copy hotkey
+
+ // Star toggles the current color in/out of favorites; keep it lit when the color matches.
+ FavoriteButton.OnPressed += _ =>
+ {
+ _favorites.Toggle(_color);
+ UpdateFavoriteStar();
+ };
+ _favorites.FavoritesChanged += UpdateFavoriteStar;
+ UpdateFavoriteStar();
+ // Exodus-End
+
PickerOpen.OnPressed += _ =>
{
if (_picker is null)
@@ -118,8 +138,68 @@ private void OnColorPicked(Color color)
_color = color;
UpdateDecalPlacementInfo();
RefreshList();
+ UpdateFavoriteStar(); // Exodus
+ }
+
+ // Exodus-Start
+ // Reflect whether the current color is saved as a favorite (★ lit / ☆ unlit).
+ private void UpdateFavoriteStar()
+ {
+ // These run from a system event; a dead instance must not touch its controls (it would
+ // throw and abort the event's multicast before live windows run).
+ if (Disposed)
+ return;
+
+ var saved = _favorites.Contains(_color);
+ FavoriteButton.Text = saved ? "★" : "☆";
+ FavoriteButton.Modulate = saved ? Color.Gold : Color.White;
+ }
+
+ // Apply a color copied from a decal via the eyedropper tool.
+ private void OnEyedropperPicked(Color color)
+ {
+ if (Disposed)
+ return;
+
+ // Make sure the copied color is actually used when placing.
+ _useColor = true;
+ EnableColor.Pressed = true;
+
+ ColorPicker.Color = color;
+ OnColorPicked(color);
}
+ // Mirror a decal copied from the map (hotkey "O") into the placer settings.
+ private void OnDecalCopied(Decal decal)
+ {
+ if (Disposed)
+ return;
+
+ if (!_prototype.HasIndex(decal.Id))
+ return;
+
+ _selected = decal.Id;
+
+ _useColor = decal.Color != null;
+ EnableColor.Pressed = _useColor;
+ _color = decal.Color ?? Color.White;
+ ColorPicker.Color = _color;
+
+ _rotation = (float) decal.Angle.Degrees;
+ RotationSpinBox.Value = _rotation;
+
+ _zIndex = decal.ZIndex;
+ ZIndexSpinBox.Value = _zIndex;
+
+ _cleanable = decal.Cleanable;
+ EnableCleanable.Pressed = _cleanable;
+
+ UpdateDecalPlacementInfo();
+ RefreshList();
+ UpdateFavoriteStar();
+ }
+ // Exodus-End
+
private void UpdateDecalPlacementInfo()
{
if (_selected is null)
@@ -175,6 +255,17 @@ private void ButtonOnPressed(ButtonEventArgs obj)
if (obj.Button.Name == null)
return;
+ // Exodus-Start: clicking the already-selected decal again clears the selection
+ // instead of leaving it stuck to the cursor.
+ if (obj.Button.Name == _selected)
+ {
+ _selected = null;
+ _decalPlacementSystem.ClearDecal();
+ RefreshList();
+ return;
+ }
+ // Exodus-End
+
SelectDecal(obj.Button.Name);
}
@@ -221,4 +312,20 @@ public override void Close()
base.Close();
_decalPlacementSystem.SetActive(false);
}
+
+ // Exodus-Start: drop subscriptions and owned windows when this instance is destroyed.
+ protected override void Dispose(bool disposing)
+ {
+ base.Dispose(disposing);
+ if (disposing)
+ {
+ _decalPlacementSystem.EyedropperPicked -= OnEyedropperPicked;
+ _decalPlacementSystem.DecalCopied -= OnDecalCopied;
+ _favorites.FavoritesChanged -= UpdateFavoriteStar;
+ // The palette picker is owned by this window; dispose it so its own
+ // FavoritesChanged subscription doesn't leak past this window's lifetime.
+ _picker?.Dispose();
+ }
+ }
+ // Exodus-End
}
diff --git a/Content.Client/Decals/UI/PaletteColorPicker.xaml.cs b/Content.Client/Decals/UI/PaletteColorPicker.xaml.cs
index 8fc4cf2cbf5..6e69b35df3f 100644
--- a/Content.Client/Decals/UI/PaletteColorPicker.xaml.cs
+++ b/Content.Client/Decals/UI/PaletteColorPicker.xaml.cs
@@ -1,4 +1,5 @@
-using Content.Shared.Decals;
+using Content.Client._Exodus.Decals; // Exodus
+using Content.Shared.Decals;
using Robust.Client.AutoGenerated;
using Robust.Client.Graphics;
using Robust.Client.ResourceManagement;
@@ -13,14 +14,23 @@ public sealed partial class PaletteColorPicker : DefaultWindow
{
[Dependency] private IPrototypeManager _prototypeManager = default!;
[Dependency] private IResourceCache _resourceCache = default!;
+ [Dependency] private IEntityManager _entManager = default!; // Exodus
private readonly TextureResource _tex;
+ // Exodus-Start
+ // Sentinel metadata marking the synthetic "Favorites" entry (not a prototype).
+ private static readonly object FavoritesMarker = new();
+ private readonly FavoriteDecalColorsSystem _favorites;
+ // Exodus-End
+
public PaletteColorPicker()
{
RobustXamlLoader.Load(this);
IoCManager.InjectDependencies(this);
+ _favorites = _entManager.System(); // Exodus
+
_tex = _resourceCache.GetResource("/Textures/Interface/Nano/button.svg.96dpi.png");
var i = 0;
@@ -33,6 +43,12 @@ public PaletteColorPicker()
i += 1;
}
+ // Exodus-Start: append the user's favorites as a synthetic palette.
+ Palettes.AddItem(Loc.GetString("decal-placer-palette-favorites"));
+ Palettes.SetItemMetadata(i, FavoritesMarker);
+ _favorites.FavoritesChanged += OnFavoritesChanged;
+ // Exodus-End
+
Palettes.OnItemSelected += args =>
{
Palettes.SelectId(args.Id);
@@ -46,11 +62,44 @@ public PaletteColorPicker()
private void SetupList()
{
PaletteList.Clear();
- foreach (var (color, value) in (Palettes.SelectedMetadata as ColorPalettePrototype)!.Colors)
+
+ // Exodus: the Favorites entry is dynamic.
+ if (Palettes.SelectedMetadata is ColorPalettePrototype palette)
{
- var item = PaletteList.AddItem(color, _tex.Texture);
- item.Metadata = value;
- item.IconModulate = value;
+ foreach (var (name, value) in palette.Colors)
+ {
+ var item = PaletteList.AddItem(name, _tex.Texture);
+ item.Metadata = value;
+ item.IconModulate = value;
+ }
}
+ else
+ {
+ foreach (var color in _favorites.Colors)
+ {
+ var item = PaletteList.AddItem(color.ToHex(), _tex.Texture);
+ item.Metadata = color;
+ item.IconModulate = color;
+ }
+ }
+ }
+
+ // Exodus-Start
+ // Refresh while the favorites palette is shown.
+ private void OnFavoritesChanged()
+ {
+ if (Disposed)
+ return;
+
+ if (Palettes.SelectedMetadata is not ColorPalettePrototype)
+ SetupList();
+ }
+
+ protected override void Dispose(bool disposing)
+ {
+ base.Dispose(disposing);
+ if (disposing)
+ _favorites.FavoritesChanged -= OnFavoritesChanged;
}
+ // Exodus-End
}
diff --git a/Content.Client/Input/ContentContexts.cs b/Content.Client/Input/ContentContexts.cs
index f9d3662f050..ef1fe42b60c 100644
--- a/Content.Client/Input/ContentContexts.cs
+++ b/Content.Client/Input/ContentContexts.cs
@@ -46,6 +46,11 @@ public static void SetupContexts(IInputContextContainer contexts)
// Not in engine, because engine cannot check for sanbox/admin status before starting placement.
common.AddFunction(ContentKeyFunctions.EditorCopyObject);
+ // Exodus-Start: copy decal(s) under the cursor; only act while the decal spawn window is open.
+ common.AddFunction(ContentKeyFunctions.EditorCopyDecal);
+ common.AddFunction(ContentKeyFunctions.EditorCopyDecalStack);
+ // Exodus-End
+
// Not in engine because the engine doesn't understand what a flipped object is
common.AddFunction(ContentKeyFunctions.EditorFlipObject);
diff --git a/Content.Client/Mapping/MappingScreen.xaml.cs b/Content.Client/Mapping/MappingScreen.xaml.cs
index 6ef9ce578bd..11f9c261ddd 100644
--- a/Content.Client/Mapping/MappingScreen.xaml.cs
+++ b/Content.Client/Mapping/MappingScreen.xaml.cs
@@ -209,4 +209,13 @@ public void UnPressActionsExcept(Control except)
Pick.Pressed = Pick == except;
Delete.Pressed = Delete == except;
}
+
+ // Exodus-Start: Dispose palette picker explicitly so its FavoritesChanged subscription doesn't leak.
+ protected override void Dispose(bool disposing)
+ {
+ base.Dispose(disposing);
+ if (disposing)
+ _picker?.Dispose();
+ }
+ // Exodus-End
}
diff --git a/Content.Client/Options/UI/Tabs/KeyRebindTab.xaml.cs b/Content.Client/Options/UI/Tabs/KeyRebindTab.xaml.cs
index 1511da0f23c..e52530ffdfa 100644
--- a/Content.Client/Options/UI/Tabs/KeyRebindTab.xaml.cs
+++ b/Content.Client/Options/UI/Tabs/KeyRebindTab.xaml.cs
@@ -307,6 +307,10 @@ void AddCheckBox(string checkBoxName, bool currentState, Action _colors = new();
+
+ /// Raised whenever the favorites list changes, so open windows can refresh.
+ public event Action? FavoritesChanged;
+
+ public IReadOnlyList Colors => _colors;
+
+ public override void Initialize()
+ {
+ base.Initialize();
+ Load();
+ }
+
+ public bool Contains(Color color)
+ {
+ return _colors.Any(c => SameColor(c, color));
+ }
+
+ /// Adds the color, or removes it if already a favorite.
+ public bool Toggle(Color color)
+ {
+ if (Remove(color))
+ return false;
+
+ _colors.Add(color);
+ Save();
+ return true;
+ }
+
+ /// Removes the color if present. Returns true if something was removed.
+ private bool Remove(Color color)
+ {
+ var index = _colors.FindIndex(c => SameColor(c, color));
+ if (index < 0)
+ return false;
+
+ _colors.RemoveAt(index);
+ Save();
+ return true;
+ }
+
+ // Compare at the 8-bit precision colors are stored/serialized at, without allocating hex strings.
+ private static bool SameColor(Color a, Color b)
+ => a.RByte == b.RByte && a.GByte == b.GByte && a.BByte == b.BByte && a.AByte == b.AByte;
+
+ private void Load()
+ {
+ _colors.Clear();
+ var raw = _cfg.GetCVar(XCVars.DecalFavoriteColors);
+ foreach (var token in raw.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
+ {
+ if (Color.TryFromHex(token) is { } color)
+ _colors.Add(color);
+ }
+ }
+
+ private void Save()
+ {
+ _cfg.SetCVar(XCVars.DecalFavoriteColors, string.Join(';', _colors.Select(c => c.ToHex())));
+ _cfg.SaveToFile();
+ FavoritesChanged?.Invoke();
+ }
+}
diff --git a/Content.Server/Decals/DecalSystem.cs b/Content.Server/Decals/DecalSystem.cs
index 59ce4eedfc3..92c97f27d7d 100644
--- a/Content.Server/Decals/DecalSystem.cs
+++ b/Content.Server/Decals/DecalSystem.cs
@@ -266,22 +266,53 @@ private void OnDecalRemovalRequest(RequestDecalRemovalEvent ev, EntitySessionEve
if (gridId == null)
return;
- // remove all decals on the same tile
- foreach (var (decalId, decal) in GetDecalsInRange(gridId.Value, ev.Coordinates.Position))
+ // Exodus-Start: remove only the topmost decal per click instead of wiping the whole tile.
+ // Original behaviour removed every decal in range:
+ // // remove all decals on the same tile
+ // foreach (var (decalId, decal) in GetDecalsInRange(gridId.Value, ev.Coordinates.Position))
+ // {
+ // if (eventArgs.SenderSession.AttachedEntity != null)
+ // {
+ // _adminLogger.Add(LogType.CrayonDraw, LogImpact.High,
+ // $"{ToPrettyString(eventArgs.SenderSession.AttachedEntity.Value):actor} removed a {decal.Color} {decal.Id} at {ev.Coordinates}");
+ // }
+ // else
+ // {
+ // _adminLogger.Add(LogType.CrayonDraw, LogImpact.High,
+ // $"{eventArgs.SenderSession.Name} removed a {decal.Color} {decal.Id} at {ev.Coordinates}");
+ // }
+ //
+ // RemoveDecal(gridId.Value, decalId);
+ // }
+
+ // Pick the decal on top (highest ZIndex, then highest id).
+ (uint Index, Decal Decal)? topmost = null;
+ foreach (var entry in GetDecalsInRange(gridId.Value, ev.Coordinates.Position))
{
- if (eventArgs.SenderSession.AttachedEntity != null)
+ if (topmost is not { } top
+ || entry.Decal.ZIndex > top.Decal.ZIndex
+ || (entry.Decal.ZIndex == top.Decal.ZIndex && entry.Index > top.Index))
{
- _adminLogger.Add(LogType.CrayonDraw, LogImpact.High,
- $"{ToPrettyString(eventArgs.SenderSession.AttachedEntity.Value):actor} removed a {decal.Color} {decal.Id} at {ev.Coordinates}");
- }
- else
- {
- _adminLogger.Add(LogType.CrayonDraw, LogImpact.High,
- $"{eventArgs.SenderSession.Name} removed a {decal.Color} {decal.Id} at {ev.Coordinates}");
+ topmost = entry;
}
+ }
- RemoveDecal(gridId.Value, decalId);
+ if (topmost is not { } target)
+ return;
+
+ if (eventArgs.SenderSession.AttachedEntity != null)
+ {
+ _adminLogger.Add(LogType.CrayonDraw, LogImpact.High,
+ $"{ToPrettyString(eventArgs.SenderSession.AttachedEntity.Value):actor} removed a {target.Decal.Color} {target.Decal.Id} at {ev.Coordinates}");
}
+ else
+ {
+ _adminLogger.Add(LogType.CrayonDraw, LogImpact.High,
+ $"{eventArgs.SenderSession.Name} removed a {target.Decal.Color} {target.Decal.Id} at {ev.Coordinates}");
+ }
+
+ RemoveDecal(gridId.Value, target.Index);
+ // Exodus-End
}
protected override void DirtyChunk(EntityUid uid, Vector2i chunkIndices, DecalChunk chunk)
diff --git a/Content.Shared/Input/ContentKeyFunctions.cs b/Content.Shared/Input/ContentKeyFunctions.cs
index 0ddc086dfad..1d0883a7065 100644
--- a/Content.Shared/Input/ContentKeyFunctions.cs
+++ b/Content.Shared/Input/ContentKeyFunctions.cs
@@ -147,6 +147,10 @@ public static BoundKeyFunction[] GetHotbarBoundKeys() =>
public static readonly BoundKeyFunction Vote8 = "Vote8";
public static readonly BoundKeyFunction Vote9 = "Vote9";
public static readonly BoundKeyFunction EditorCopyObject = "EditorCopyObject";
+ // Exodus-Start: copy decal(s) under the cursor in the decal placer
+ public static readonly BoundKeyFunction EditorCopyDecal = "EditorCopyDecal";
+ public static readonly BoundKeyFunction EditorCopyDecalStack = "EditorCopyDecalStack";
+ // Exodus-End
public static readonly BoundKeyFunction EditorFlipObject = "EditorFlipObject";
public static readonly BoundKeyFunction InspectEntity = "InspectEntity";
diff --git a/Content.Shared/_Exodus/CCVar/XCVars.cs b/Content.Shared/_Exodus/CCVar/XCVars.cs
index 2e548bddbf4..2c11e7804b5 100644
--- a/Content.Shared/_Exodus/CCVar/XCVars.cs
+++ b/Content.Shared/_Exodus/CCVar/XCVars.cs
@@ -15,4 +15,11 @@ public sealed partial class XCVars
public static readonly CVarDef WebAPIToken =
CVarDef.Create("exds.webapi_token", "", CVar.SERVERONLY);
+
+ ///
+ /// User's favorite decal colors, stored as a ';'-separated list of hex colors.
+ /// Client-only and archived to client_config.toml so it survives relogs/restarts.
+ ///
+ public static readonly CVarDef DecalFavoriteColors =
+ CVarDef.Create("exds.decal_favorite_colors", "", CVar.CLIENTONLY | CVar.ARCHIVE);
}
diff --git a/Resources/Locale/en-US/_Exodus/decals/decal-window.ftl b/Resources/Locale/en-US/_Exodus/decals/decal-window.ftl
new file mode 100644
index 00000000000..bf7fe60392d
--- /dev/null
+++ b/Resources/Locale/en-US/_Exodus/decals/decal-window.ftl
@@ -0,0 +1,8 @@
+decal-placer-window-eyedropper = Pick Color
+
+ui-options-function-editor-copy-decal = Copy decal under cursor
+ui-options-function-editor-copy-decal-stack = Copy all decals under cursor
+
+decal-placer-window-favorite-label = Favorite color
+decal-placer-window-favorite-tooltip = Save the current color to the Favorites palette (click again to remove)
+decal-placer-palette-favorites = Favorites
diff --git a/Resources/Locale/ru-RU/_Exodus/decals/decal-window.ftl b/Resources/Locale/ru-RU/_Exodus/decals/decal-window.ftl
new file mode 100644
index 00000000000..8a1bf0b1807
--- /dev/null
+++ b/Resources/Locale/ru-RU/_Exodus/decals/decal-window.ftl
@@ -0,0 +1,8 @@
+decal-placer-window-eyedropper = Взять цвет
+
+ui-options-function-editor-copy-decal = Копировать декаль под курсором
+ui-options-function-editor-copy-decal-stack = Копировать все декали под курсором
+
+decal-placer-window-favorite-label = Избранный цвет
+decal-placer-window-favorite-tooltip = Сохранить текущий цвет в палитру «Избранное» (повторный клик удаляет)
+decal-placer-palette-favorites = Избранное
diff --git a/Resources/keybinds.yml b/Resources/keybinds.yml
index 5c71546995e..2b934e44871 100644
--- a/Resources/keybinds.yml
+++ b/Resources/keybinds.yml
@@ -165,6 +165,15 @@ binds:
- function: EditorCopyObject
type: State
key: P
+# Exodus-Start: copy decal under cursor (O) / copy whole decal stack (Ctrl+O)
+- function: EditorCopyDecal
+ type: State
+ key: O
+- function: EditorCopyDecalStack
+ type: State
+ key: O
+ mod1: Control
+# Exodus-End
- function: SwapHands
type: State
key: X