diff --git a/Content.Client/ClickMapManager.cs b/Content.Client/ClickMapManager.cs new file mode 100644 index 0000000000..84b07fb97b --- /dev/null +++ b/Content.Client/ClickMapManager.cs @@ -0,0 +1,196 @@ +using System; +using System.Collections.Generic; +using System.Text; +using Robust.Client.Graphics; +using Robust.Client.Interfaces.ResourceManagement; +using Robust.Client.ResourceManagement; +using Robust.Client.Utility; +using Robust.Shared.IoC; +using Robust.Shared.Maths; +using Robust.Shared.ViewVariables; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; + +#nullable enable + +namespace Content.Client +{ + internal class ClickMapManager : IClickMapManager, IPostInjectInit + { + private const float Threshold = 0.25f; + private const int ClickRadius = 0; + + [Dependency] private readonly IResourceCache _resourceCache = default!; + + [ViewVariables] + private readonly Dictionary _textureMaps = new Dictionary(); + + [ViewVariables] private readonly Dictionary _rsiMaps = + new Dictionary(); + + public void PostInject() + { + _resourceCache.OnRawTextureLoaded += OnRawTextureLoaded; + _resourceCache.OnRsiLoaded += OnOnRsiLoaded; + } + + private void OnOnRsiLoaded(RsiLoadedEventArgs obj) + { + if (obj.Atlas is Image rgba) + { + var clickMap = ClickMap.FromImage(rgba, Threshold); + + var rsiData = new RsiClickMapData(clickMap, obj.AtlasOffsets); + _rsiMaps[obj.Resource.RSI] = rsiData; + } + } + + private void OnRawTextureLoaded(TextureLoadedEventArgs obj) + { + if (obj.Image is Image rgba) + { + _textureMaps[obj.Resource] = ClickMap.FromImage(rgba, Threshold); + } + } + + public bool IsOccluding(Texture texture, Vector2i pos) + { + if (!_textureMaps.TryGetValue(texture, out var clickMap)) + { + return false; + } + + return SampleClickMap(clickMap, pos, clickMap.Size, Vector2i.Zero); + } + + public bool IsOccluding(RSI rsi, RSI.StateId state, RSI.State.Direction dir, int frame, Vector2i pos) + { + if (!_rsiMaps.TryGetValue(rsi, out var rsiData)) + { + return false; + } + + var offset = rsiData.Offsets[state][(int) dir][frame]; + return SampleClickMap(rsiData.ClickMap, pos, rsi.Size, offset); + } + + private static bool SampleClickMap(ClickMap map, Vector2i pos, Vector2i bounds, Vector2i offset) + { + var (width, height) = bounds; + var (px, py) = pos; + + for (var x = -ClickRadius; x <= ClickRadius; x++) + { + var ox = px + x; + if (ox < 0 || ox >= width) + { + continue; + } + + for (var y = -ClickRadius; y <= ClickRadius; y++) + { + var oy = py + y; + + if (oy < 0 || oy >= height) + { + continue; + } + + if (map.IsOccluded((ox, oy) + offset)) + { + return true; + } + } + } + + return false; + } + + private sealed class RsiClickMapData + { + public readonly ClickMap ClickMap; + public readonly Dictionary Offsets; + + public RsiClickMapData(ClickMap clickMap, Dictionary offsets) + { + ClickMap = clickMap; + Offsets = offsets; + } + } + + internal sealed class ClickMap + { + [ViewVariables] private readonly byte[] _data; + + public int Width { get; } + public int Height { get; } + [ViewVariables] public Vector2i Size => (Width, Height); + + public bool IsOccluded(int x, int y) + { + var i = y * Width + x; + return (_data[i / 8] & (1 << (i % 8))) != 0; + } + + public bool IsOccluded(Vector2i vector) + { + var (x, y) = vector; + return IsOccluded(x, y); + } + + private ClickMap(byte[] data, int width, int height) + { + Width = width; + Height = height; + _data = data; + } + + public static ClickMap FromImage(Image image, float threshold) where T : unmanaged, IPixel + { + var threshByte = (byte) (threshold * 255); + var width = image.Width; + var height = image.Height; + + var dataSize = (int) Math.Ceiling(width * height / 8f); + var data = new byte[dataSize]; + + var pixelSpan = image.GetPixelSpan(); + + for (var i = 0; i < pixelSpan.Length; i++) + { + Rgba32 rgba = default; + pixelSpan[i].ToRgba32(ref rgba); + if (rgba.A >= threshByte) + { + data[i / 8] |= (byte) (1 << (i % 8)); + } + } + + return new ClickMap(data, width, height); + } + + public string DumpText() + { + var sb = new StringBuilder(); + for (var y = 0; y < Height; y++) + { + for (var x = 0; x < Width; x++) + { + sb.Append(IsOccluded(x, y) ? "1" : "0"); + } + + sb.AppendLine(); + } + + return sb.ToString(); + } + } + } + + public interface IClickMapManager + { + public bool IsOccluding(Texture texture, Vector2i pos); + + public bool IsOccluding(RSI rsi, RSI.StateId state, RSI.State.Direction dir, int frame, Vector2i pos); + } +} diff --git a/Content.Client/ClientContentIoC.cs b/Content.Client/ClientContentIoC.cs index 93e75a88f1..96c9e5a7c6 100644 --- a/Content.Client/ClientContentIoC.cs +++ b/Content.Client/ClientContentIoC.cs @@ -32,6 +32,7 @@ namespace Content.Client IoCManager.Register(); IoCManager.Register(); IoCManager.Register(); + IoCManager.Register(); } } } diff --git a/Content.Client/GameObjects/Components/ClickableComponent.cs b/Content.Client/GameObjects/Components/ClickableComponent.cs new file mode 100644 index 0000000000..3b36e8d38a --- /dev/null +++ b/Content.Client/GameObjects/Components/ClickableComponent.cs @@ -0,0 +1,146 @@ +using System; +using Robust.Client.Graphics.ClientEye; +using Robust.Client.Interfaces.GameObjects.Components; +using Robust.Shared.GameObjects; +using Robust.Shared.Interfaces.Serialization; +using Robust.Shared.IoC; +using Robust.Shared.Maths; +using Robust.Shared.Serialization; +using Robust.Shared.ViewVariables; + +#nullable enable + +namespace Content.Client.GameObjects.Components +{ + [RegisterComponent] + public sealed class ClickableComponent : Component + { + public override string Name => "Clickable"; + + [Dependency] private readonly IClickMapManager _clickMapManager = default!; + + [ViewVariables] private DirBoundData _data = default!; + + public override void ExposeData(ObjectSerializer serializer) + { + base.ExposeData(serializer); + + serializer.DataField(ref _data, "bounds", DirBoundData.Default); + } + + /// + /// Used to check whether a click worked. + /// + /// The world position that was clicked. + /// + /// The draw depth for the sprite that captured the click. + /// + /// True if the click worked, false otherwise. + public bool CheckClick(Vector2 worldPos, out int drawDepth, out uint renderOrder) + { + if (!Owner.TryGetComponent(out ISpriteComponent sprite) || !sprite.Visible) + { + drawDepth = default; + renderOrder = default; + return false; + } + + var localPos = Owner.Transform.InvWorldMatrix.Transform(worldPos); + + var worldRotation = Owner.Transform.WorldRotation; + if (sprite.Directional) + { + localPos = new Angle(worldRotation).RotateVec(localPos); + } + else + { + localPos = new Angle(MathHelper.PiOver2).RotateVec(localPos); + } + + var localOffset = localPos * EyeManager.PixelsPerMeter * (1, -1); + + var found = false; + + if (_data.All.Contains(localPos)) + { + found = true; + } + else + { + // TODO: diagonal support? + var dir = sprite.Directional ? worldRotation.GetCardinalDir() : Direction.South; + var boundsForDir = dir switch + { + Direction.East => _data.East, + Direction.North => _data.North, + Direction.South => _data.South, + Direction.West => _data.West, + _ => throw new InvalidOperationException() + }; + + if (boundsForDir.Contains(localPos)) + { + found = true; + } + } + + if (!found) + { + foreach (var layer in sprite.AllLayers) + { + if (layer.Texture != null) + { + if (_clickMapManager.IsOccluding(layer.Texture, + (Vector2i) (localOffset + layer.Texture.Size / 2f))) + { + found = true; + break; + } + } + else if (layer.RsiState != default) + { + var rsi = layer.ActualRsi; + if (rsi == null) + { + continue; + } + + var (mX, mY) = localOffset + rsi.Size / 2; + + var dir = layer.EffectiveDirection(worldRotation); + if (_clickMapManager.IsOccluding(rsi, layer.RsiState, dir, + layer.AnimationFrame, ((int) mX, (int) mY))) + { + found = true; + break; + } + } + } + } + + drawDepth = sprite.DrawDepth; + renderOrder = sprite.RenderOrder; + return found; + } + + private sealed class DirBoundData : IExposeData + { + [ViewVariables] public Box2 All; + [ViewVariables] public Box2 North; + [ViewVariables] public Box2 South; + [ViewVariables] public Box2 East; + [ViewVariables] public Box2 West; + + public static DirBoundData Default { get; } = new DirBoundData(); + + public void ExposeData(ObjectSerializer serializer) + { + serializer.DataField(ref All, "all", default); + serializer.DataField(ref North, "north", default); + serializer.DataField(ref South, "south", default); + serializer.DataField(ref East, "east", default); + serializer.DataField(ref West, "west", default); + } + } + } +} diff --git a/Content.Client/GameObjects/EntitySystems/VerbSystem.cs b/Content.Client/GameObjects/EntitySystems/VerbSystem.cs index 6e5a2a50f8..28bed30120 100644 --- a/Content.Client/GameObjects/EntitySystems/VerbSystem.cs +++ b/Content.Client/GameObjects/EntitySystems/VerbSystem.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Reflection; using System.Threading; using Content.Client.State; @@ -21,11 +22,13 @@ using Robust.Client.Player; using Robust.Client.UserInterface; using Robust.Client.UserInterface.Controls; using Robust.Client.Utility; +using Robust.Shared.Containers; using Robust.Shared.GameObjects; using Robust.Shared.GameObjects.Systems; using Robust.Shared.Input; using Robust.Shared.Input.Binding; using Robust.Shared.Interfaces.GameObjects; +using Robust.Shared.Interfaces.Map; using Robust.Shared.Interfaces.Timing; using Robust.Shared.IoC; using Robust.Shared.Log; @@ -47,6 +50,7 @@ namespace Content.Client.GameObjects.EntitySystems [Dependency] private readonly IItemSlotManager _itemSlotManager; [Dependency] private readonly IGameTiming _gameTiming; [Dependency] private readonly IUserInterfaceManager _userInterfaceManager; + [Dependency] private readonly IMapManager _mapManager; #pragma warning restore 649 private EntityList _currentEntityList; @@ -110,7 +114,9 @@ namespace Content.Client.GameObjects.EntitySystems return false; } - var entities = gameScreen.GetEntitiesUnderPosition(args.Coordinates); + var mapCoordinates = args.Coordinates.ToMap(_mapManager); + var entities = _entityManager.GetEntitiesIntersecting(mapCoordinates.MapId, + Box2.CenteredAround(mapCoordinates.Position, (0.5f, 0.5f))).ToList(); if (entities.Count == 0) { @@ -119,9 +125,20 @@ namespace Content.Client.GameObjects.EntitySystems _currentEntityList = new EntityList(); _currentEntityList.OnPopupHide += CloseAllMenus; - for (var i = 0; i < entities.Count; i++) + var first = true; + foreach (var entity in entities) { - if (i != 0) + if (!entity.TryGetComponent(out ISpriteComponent sprite) || !sprite.Visible) + { + continue; + } + + if (ContainerHelpers.TryGetContainer(entity, out var container) && !container.ShowContents) + { + continue; + } + + if (!first) { _currentEntityList.List.AddChild(new PanelContainer { @@ -130,9 +147,8 @@ namespace Content.Client.GameObjects.EntitySystems }); } - var entity = entities[i]; - _currentEntityList.List.AddChild(new EntityButton(this, entity)); + first = false; } _userInterfaceManager.ModalRoot.AddChild(_currentEntityList); diff --git a/Content.Client/State/GameScreenBase.cs b/Content.Client/State/GameScreenBase.cs index 745c0ec999..f0496f8663 100644 --- a/Content.Client/State/GameScreenBase.cs +++ b/Content.Client/State/GameScreenBase.cs @@ -17,6 +17,7 @@ using Robust.Shared.Interfaces.Map; using Robust.Shared.Interfaces.Timing; using Robust.Shared.IoC; using Robust.Shared.Map; +using Robust.Shared.Maths; using Robust.Shared.Timing; namespace Content.Client.State @@ -54,7 +55,9 @@ namespace Content.Client.State base.FrameUpdate(e); var mousePosWorld = _eyeManager.ScreenToMap(_inputManager.MouseScreenPosition); - var entityToClick = _userInterfaceManager.CurrentlyHovered != null ? null : GetEntityUnderPosition(mousePosWorld); + var entityToClick = _userInterfaceManager.CurrentlyHovered != null + ? null + : GetEntityUnderPosition(mousePosWorld); var inRange = false; if (_playerManager.LocalPlayer.ControlledEntity != null && entityToClick != null) @@ -62,7 +65,10 @@ namespace Content.Client.State var playerPos = _playerManager.LocalPlayer.ControlledEntity.Transform.MapPosition; var entityPos = entityToClick.Transform.MapPosition; inRange = _entitySystemManager.GetEntitySystem() - .InRangeUnobstructed(playerPos, entityPos, predicate:entity => entity == _playerManager.LocalPlayer.ControlledEntity || entity == entityToClick, insideBlockerValid:true); + .InRangeUnobstructed(playerPos, entityPos, + predicate: entity => + entity == _playerManager.LocalPlayer.ControlledEntity || entity == entityToClick, + insideBlockerValid: true); } InteractionOutlineComponent outline; @@ -72,6 +78,7 @@ namespace Content.Client.State { outline.UpdateInRange(inRange); } + return; } @@ -103,17 +110,18 @@ namespace Content.Client.State public IList GetEntitiesUnderPosition(MapCoordinates coordinates) { // Find all the entities intersecting our click - var entities = _entityManager.GetEntitiesIntersecting(coordinates.MapId, coordinates.Position); + var entities = _entityManager.GetEntitiesIntersecting(coordinates.MapId, + Box2.CenteredAround(coordinates.Position, (1, 1))); // Check the entities against whether or not we can click them - var foundEntities = new List<(IEntity clicked, int drawDepth)>(); + var foundEntities = new List<(IEntity clicked, int drawDepth, uint renderOrder)>(); foreach (var entity in entities) { - if (entity.TryGetComponent(out var component) + if (entity.TryGetComponent(out var component) && entity.Transform.IsMapTransform - && component.CheckClick(coordinates.Position, out var drawDepthClicked)) + && component.CheckClick(coordinates.Position, out var drawDepthClicked, out var renderOrder)) { - foundEntities.Add((entity, drawDepthClicked)); + foundEntities.Add((entity, drawDepthClicked, renderOrder)); } } @@ -126,9 +134,10 @@ namespace Content.Client.State return foundEntities.Select(a => a.clicked).ToList(); } - internal class ClickableEntityComparer : IComparer<(IEntity clicked, int depth)> + internal class ClickableEntityComparer : IComparer<(IEntity clicked, int depth, uint renderOrder)> { - public int Compare((IEntity clicked, int depth) x, (IEntity clicked, int depth) y) + public int Compare((IEntity clicked, int depth, uint renderOrder) x, + (IEntity clicked, int depth, uint renderOrder) y) { var val = x.depth.CompareTo(y.depth); if (val != 0) @@ -136,9 +145,24 @@ namespace Content.Client.State return val; } + // Turning this off it can make picking stuff out of lockers and such up a bit annoying. + /* + val = x.renderOrder.CompareTo(y.renderOrder); + if (val != 0) + { + return val; + } + */ + var transx = x.clicked.Transform; var transy = y.clicked.Transform; - return transx.GridPosition.Y.CompareTo(transy.GridPosition.Y); + val = transx.GridPosition.Y.CompareTo(transy.GridPosition.Y); + if (val != 0) + { + return val; + } + + return x.clicked.Uid.CompareTo(y.clicked.Uid); } } diff --git a/Content.Server/EntryPoint.cs b/Content.Server/EntryPoint.cs index 5329f8b588..5751fc51a0 100644 --- a/Content.Server/EntryPoint.cs +++ b/Content.Server/EntryPoint.cs @@ -43,6 +43,7 @@ namespace Content.Server "ItemStatus", "Marker", "EmergencyLight", + "Clickable", }; foreach (var ignoreName in registerIgnore) diff --git a/Content.Tests/Client/ClickMapTest.cs b/Content.Tests/Client/ClickMapTest.cs new file mode 100644 index 0000000000..ff3b26a016 --- /dev/null +++ b/Content.Tests/Client/ClickMapTest.cs @@ -0,0 +1,49 @@ +using Content.Client; +using NUnit.Framework; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; + +namespace Content.Tests.Client +{ + [TestFixture] + public class ClickMapTest + { + [Test] + public void TestBasic() + { + var img = new Image(2, 2) + { + [0, 0] = new Rgba32(0, 0, 0, 0f), + [1, 0] = new Rgba32(0, 0, 0, 1f), + [0, 1] = new Rgba32(0, 0, 0, 1f), + [1, 1] = new Rgba32(0, 0, 0, 0f) + }; + + var clickMap = ClickMapManager.ClickMap.FromImage(img, 0.5f); + + Assert.That(clickMap.IsOccluded(0, 0), Is.False); + Assert.That(clickMap.IsOccluded(1, 0), Is.True); + Assert.That(clickMap.IsOccluded(0, 1), Is.True); + Assert.That(clickMap.IsOccluded(1, 1), Is.False); + } + + [Test] + public void TestThreshold() + { + var img = new Image(2, 2) + { + [0, 0] = new Rgba32(0, 0, 0, 0f), + [1, 0] = new Rgba32(0, 0, 0, 0.25f), + [0, 1] = new Rgba32(0, 0, 0, 0.75f), + [1, 1] = new Rgba32(0, 0, 0, 1f) + }; + + var clickMap = ClickMapManager.ClickMap.FromImage(img, 0.5f); + + Assert.That(clickMap.IsOccluded(0, 0), Is.False); + Assert.That(clickMap.IsOccluded(1, 0), Is.False); + Assert.That(clickMap.IsOccluded(0, 1), Is.True); + Assert.That(clickMap.IsOccluded(1, 1), Is.True); + } + } +} diff --git a/Resources/Prototypes/Entities/Buildings/lighting.yml b/Resources/Prototypes/Entities/Buildings/lighting.yml index faecf97864..36c32811cf 100644 --- a/Resources/Prototypes/Entities/Buildings/lighting.yml +++ b/Resources/Prototypes/Entities/Buildings/lighting.yml @@ -3,6 +3,11 @@ name: "unpowered light" components: - type: Clickable + bounds: + south: 0.25,-0.5,0.75,0.5 + north: -0.25,-0.5,0.25,0.5 + east: -0.5,-0.5,0.5,0 + west: -0.5,0,0.5,0.5 - type: InteractionOutline - type: Collidable - type: LoopingSound diff --git a/Resources/Prototypes/Entities/Items/materials.yml b/Resources/Prototypes/Entities/Items/materials.yml index f2e0c0d0eb..2c70e2cf58 100644 --- a/Resources/Prototypes/Entities/Items/materials.yml +++ b/Resources/Prototypes/Entities/Items/materials.yml @@ -69,6 +69,9 @@ - type: Icon texture: Objects/Tools/cable_coil.png - type: WirePlacer + - type: Clickable + bounds: + all: -0.15,-0.15,0.15,0.15 - type: entity id: CableStack1