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 @@ + + +