From 0e4c7e67ab37bcecfa6feceaa716526f11b411bf Mon Sep 17 00:00:00 2001 From: Leon Friedrich <60421075+ElectroJr@users.noreply.github.com> Date: Wed, 9 Feb 2022 15:11:06 +1300 Subject: [PATCH] Redo ClickableComponent (#6221) --- .../Clickable/ClickableComponent.cs | 218 ++++++++++-------- .../Tests/ClickableTest.cs | 5 + 2 files changed, 126 insertions(+), 97 deletions(-) diff --git a/Content.Client/Clickable/ClickableComponent.cs b/Content.Client/Clickable/ClickableComponent.cs index 564df8a706..5cb03a5d60 100644 --- a/Content.Client/Clickable/ClickableComponent.cs +++ b/Content.Client/Clickable/ClickableComponent.cs @@ -7,7 +7,7 @@ using Robust.Shared.IoC; using Robust.Shared.Maths; using Robust.Shared.Serialization.Manager.Attributes; using Robust.Shared.ViewVariables; -using TerraFX.Interop.Windows; +using static Robust.Client.GameObjects.SpriteComponent; namespace Content.Client.Clickable { @@ -15,11 +15,14 @@ namespace Content.Client.Clickable public sealed class ClickableComponent : Component { [Dependency] private readonly IClickMapManager _clickMapManager = default!; + [Dependency] private readonly IEyeManager _eyeManager = default!; + [Dependency] private readonly IEntityManager _entMan = default!; - [ViewVariables] [DataField("bounds")] private DirBoundData? _data; + [ViewVariables] [DataField("bounds")] private DirBoundData? Bounds; /// - /// Used to check whether a click worked. + /// Used to check whether a click worked. Will first check if the click falls inside of some explicit bounding + /// boxes (see ). If that fails, attempts to use automatically generated click maps. /// /// The world position that was clicked. /// @@ -28,106 +31,129 @@ namespace Content.Client.Clickable /// True if the click worked, false otherwise. public bool CheckClick(Vector2 worldPos, out int drawDepth, out uint renderOrder) { - var entMan = IoCManager.Resolve(); - if (!entMan.TryGetComponent(Owner, out ISpriteComponent? sprite) || !sprite.Visible) + if (!_entMan.TryGetComponent(Owner, out ISpriteComponent? sprite) || !sprite.Visible) { drawDepth = default; renderOrder = default; return false; } - var transform = entMan.GetComponent(Owner); - var localPos = transform.InvWorldMatrix.Transform(worldPos); - var spriteMatrix = Matrix3.Invert(sprite.GetLocalMatrix()); - - localPos = spriteMatrix.Transform(localPos); - - var found = false; - var worldRotation = transform.WorldRotation; - - if (_data != null) - { - if (_data.All.Contains(localPos)) - { - found = true; - } - else - { - // TODO: diagonal support? - - var modAngle = sprite.NoRotation - ? SpriteComponent.CalcRectWorldAngle(worldRotation, 4) - : Angle.Zero; - var dir = sprite.EnableDirectionOverride - ? sprite.DirectionOverride - : worldRotation.GetCardinalDir(); - - modAngle += dir.ToAngle(); - - var layerPos = modAngle.RotateVec(localPos); - - 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(layerPos)) - { - found = true; - } - } - } - - if (!found) - { - foreach (var layer in sprite.AllLayers) - { - if (!layer.Visible) continue; - - var dirCount = sprite.GetLayerDirectionCount(layer); - var dir = layer.EffectiveDirection(worldRotation); - var modAngle = sprite.NoRotation ? SpriteComponent.CalcRectWorldAngle(worldRotation, dirCount) : Angle.Zero; - modAngle += dir.Convert().ToAngle(); - - var layerPos = modAngle.RotateVec(localPos); - - var localOffset = layerPos * EyeManager.PixelsPerMeter * (1, -1); - 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; - - if (_clickMapManager.IsOccluding(rsi, layer.RsiState, dir, - layer.AnimationFrame, ((int) mX, (int) mY))) - { - found = true; - break; - } - } - } - } - drawDepth = sprite.DrawDepth; renderOrder = sprite.RenderOrder; - return found; + + var transform = _entMan.GetComponent(Owner); + var worldRot = transform.WorldRotation; + var invSpriteMatrix = Matrix3.CreateTransform(Vector2.Zero, -sprite.Rotation, (1,1)/sprite.Scale); + var relativeRotation = worldRot + _eyeManager.CurrentEye.Rotation; + + // localPos is the clicked location in the entity's coordinate frame, but with the sprite offset removed. + var localPos = transform.InvWorldMatrix.Transform(worldPos) - sprite.Offset; + + // Check explicitly defined click-able bounds + if (CheckDirBound(sprite, relativeRotation, localPos, invSpriteMatrix)) + return true; + + // Next check each individual sprite layer using automatically computed click maps. + foreach (var spriteLayer in sprite.AllLayers) + { + if (!spriteLayer.Visible || spriteLayer is not Layer layer) + continue; + + // How many orientations does this rsi have? + var dirCount = sprite.GetLayerDirectionCount(layer); + + // If the sprite does not actually rotate we need to fix the rotation that was added to localPos via invSpriteMatrix + var modAngle = Angle.Zero; + if (sprite.NoRotation) + modAngle += CalcRectWorldAngle(relativeRotation, dirCount); + + // Check the layer's texture, if it has one + if (layer.Texture != null) + { + // Convert to sprite-coordinates. This includes sprite matrix (scale, rotation), and noRot corrections (modAngle) + // Recall that the sprite offset was already removed + var spritePos = invSpriteMatrix.Transform(modAngle.RotateVec(localPos)); + + // Convert to image coordinates + var imagePos = (Vector2i) (spritePos * EyeManager.PixelsPerMeter * (1, -1) + layer.Texture.Size / 2f); + + if (_clickMapManager.IsOccluding(layer.Texture, imagePos)) + return true; + } + + // As the texture failed, we check the RSI next + if (layer.State == null || layer.ActualRsi is not RSI rsi || !rsi.TryGetState(layer.State, out var state)) + continue; + + // Get the sprite direction, in the absence of any direction offset or override. this is just for + // determining the angle-snapping correction for directional sprites. + var dir = relativeRotation.ToRsiDirection(state.Directions); + + // Add the correction to the sprite angle due to the fact that it is a directional sprite. This is some + // multiple of 90 or 45 degrees for 4/8 directional sprites, or 0 for one-directional sprites. + modAngle += dir.Convert().ToAngle(); + + // TODO SPRITE LAYER ROTATION + // Currently this doesn't support layers with scale & rotation. Whenever/if-ever that should be added, + // this needs fixing. See also Layer.CalculateBoundingBox and other engine code with similar warnings. + + // Convert to sprite-coordinates. This is the same as for the texture check, but now also includes + // directional angle-snapping corrections in modAngle (+ the previous NoRot stuff) + var layerPos = invSpriteMatrix.Transform(modAngle.RotateVec(localPos)); + + // Convert to image coordinates + var layerImagePos = (Vector2i) (layerPos * EyeManager.PixelsPerMeter * (1, -1) + layer.ActualRsi.Size / 2f); + + // Next, to get the right click map we need the "direction" of this layer that is actually being used to draw the sprite on the screen. + // This **can** differ from the dir defined before, but can also just be the same. + if (sprite.EnableDirectionOverride) + dir = sprite.DirectionOverride.Convert(state.Directions);; + dir = dir.OffsetRsiDir(layer.DirOffset); + + if (_clickMapManager.IsOccluding(layer.ActualRsi, layer.State, dir, layer.AnimationFrame, layerImagePos)) + return true; + } + + drawDepth = default; + renderOrder = default; + return false; + } + + public bool CheckDirBound(ISpriteComponent sprite, Angle relativeRotation, Vector2 localPos, Matrix3 spriteMatrix) + { + if (Bounds == null) + return false; + + // Here we get sprite orientation, either from an explicit override or just from the relative rotation + var direction = relativeRotation.GetCardinalDir(); + + // Assuming the sprite snaps to 4 orientations we need to adjust our localPos relative to the entity by 90 + // degree steps. Effectively, this accounts for the fact that the entity's world rotation + sprite rotation + // does not match the **actual** drawn rotation. + var modAngle = direction.ToAngle(); + + // If our sprite does not rotate at all, we shouldn't have been bothering with all that rotation logic, + // but it sorta came for free with the matrix transform, but we gotta undo that now. + if (sprite.NoRotation) + modAngle += CalcRectWorldAngle(relativeRotation, 4); + + var spritePos = spriteMatrix.Transform(modAngle.RotateVec(localPos)); + + // First, check the bounding box that is valid for all orientations + if (Bounds.All.Contains(spritePos)) + return true; + + // Next, get and check the appropriate bounding box for the current sprite orientation + var boundsForDir = (sprite.EnableDirectionOverride ? sprite.DirectionOverride : direction) switch + { + Direction.East => Bounds.East, + Direction.North => Bounds.North, + Direction.South => Bounds.South, + Direction.West => Bounds.West, + _ => throw new InvalidOperationException() + }; + + return boundsForDir.Contains(spritePos); } [DataDefinition] @@ -138,8 +164,6 @@ namespace Content.Client.Clickable [ViewVariables] [DataField("south")] public Box2 South; [ViewVariables] [DataField("east")] public Box2 East; [ViewVariables] [DataField("west")] public Box2 West; - - public static DirBoundData Default { get; } = new(); } } } diff --git a/Content.IntegrationTests/Tests/ClickableTest.cs b/Content.IntegrationTests/Tests/ClickableTest.cs index 9f98ccc03f..263d6fed3f 100644 --- a/Content.IntegrationTests/Tests/ClickableTest.cs +++ b/Content.IntegrationTests/Tests/ClickableTest.cs @@ -4,6 +4,7 @@ using System.Threading.Tasks; using Content.Client.Clickable; using Content.Server.GameTicking; using NUnit.Framework; +using Robust.Client.Graphics; using Robust.Server.GameObjects; using Robust.Shared; using Robust.Shared.GameObjects; @@ -71,6 +72,7 @@ namespace Content.IntegrationTests.Tests EntityUid entity = default; var clientEntManager = _client.ResolveDependency(); var serverEntManager = _server.ResolveDependency(); + var eyeManager = _client.ResolveDependency(); var mapManager = _server.ResolveDependency(); var gameTicker = _server.ResolveDependency().GetEntitySystem(); @@ -92,6 +94,9 @@ namespace Content.IntegrationTests.Tests await _client.WaitPost(() => { + // these tests currently all assume player eye is 0 + eyeManager.CurrentEye.Rotation = 0; + var clickable = clientEntManager.GetComponent(entity); hit = clickable.CheckClick((clickPosX, clickPosY) + worldPos!.Value, out _, out _);