Sprite-based click detection.

This commit is contained in:
Pieter-Jan Briers
2020-06-16 16:00:19 +02:00
parent 310e765502
commit 4136388028
9 changed files with 456 additions and 15 deletions

View File

@@ -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<Texture, ClickMap> _textureMaps = new Dictionary<Texture, ClickMap>();
[ViewVariables] private readonly Dictionary<RSI, RsiClickMapData> _rsiMaps =
new Dictionary<RSI, RsiClickMapData>();
public void PostInject()
{
_resourceCache.OnRawTextureLoaded += OnRawTextureLoaded;
_resourceCache.OnRsiLoaded += OnOnRsiLoaded;
}
private void OnOnRsiLoaded(RsiLoadedEventArgs obj)
{
if (obj.Atlas is Image<Rgba32> 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<Rgba32> 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<RSI.StateId, Vector2i[][]> Offsets;
public RsiClickMapData(ClickMap clickMap, Dictionary<RSI.StateId, Vector2i[][]> 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<T>(Image<T> image, float threshold) where T : unmanaged, IPixel<T>
{
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);
}
}

View File

@@ -32,6 +32,7 @@ namespace Content.Client
IoCManager.Register<IItemSlotManager, ItemSlotManager>();
IoCManager.Register<IStylesheetManager, StylesheetManager>();
IoCManager.Register<IScreenshotHook, ScreenshotHook>();
IoCManager.Register<IClickMapManager, ClickMapManager>();
}
}
}

View File

@@ -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);
}
/// <summary>
/// Used to check whether a click worked.
/// </summary>
/// <param name="worldPos">The world position that was clicked.</param>
/// <param name="drawDepth">
/// The draw depth for the sprite that captured the click.
/// </param>
/// <returns>True if the click worked, false otherwise.</returns>
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);
}
}
}
}

View File

@@ -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);

View File

@@ -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<SharedInteractionSystem>()
.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<IEntity> 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<IClientClickableComponent>(out var component)
if (entity.TryGetComponent<ClickableComponent>(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);
}
}

View File

@@ -43,6 +43,7 @@ namespace Content.Server
"ItemStatus",
"Marker",
"EmergencyLight",
"Clickable",
};
foreach (var ignoreName in registerIgnore)

View File

@@ -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<Rgba32>(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<Rgba32>(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);
}
}
}

View File

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

View File

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