Improve InteractionSystem range & BUI checks (#27999)

* Improve InteractionSystem range & BUI checks

* Ghost fixes

* AAA

* Fix test

* fix nullable

* revert to broadcast event

* Fixes for eengine PR

* Ah buckle code

* )
This commit is contained in:
Leon Friedrich
2024-05-24 17:03:03 +12:00
committed by GitHub
parent 01e7c9c37e
commit 76e13eed35
18 changed files with 232 additions and 248 deletions

View File

@@ -1,11 +1,10 @@
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using Content.Shared.ActionBlocker;
using Content.Shared.Administration;
using Content.Shared.Administration.Logs;
using Content.Shared.Administration.Managers;
using Content.Shared.CombatMode;
using Content.Shared.Database;
using Content.Shared.Ghost;
using Content.Shared.Hands;
using Content.Shared.Hands.Components;
using Content.Shared.Input;
@@ -15,12 +14,13 @@ using Content.Shared.Inventory;
using Content.Shared.Inventory.Events;
using Content.Shared.Item;
using Content.Shared.Movement.Components;
using Content.Shared.Movement.Pulling.Components;
using Content.Shared.Movement.Pulling.Systems;
using Content.Shared.Physics;
using Content.Shared.Popups;
using Content.Shared.Storage;
using Content.Shared.Tag;
using Content.Shared.Timing;
using Content.Shared.UserInterface;
using Content.Shared.Verbs;
using Content.Shared.Wall;
using JetBrains.Annotations;
@@ -36,6 +36,7 @@ using Robust.Shared.Player;
using Robust.Shared.Random;
using Robust.Shared.Serialization;
using Robust.Shared.Timing;
using Robust.Shared.Utility;
#pragma warning disable 618
@@ -50,12 +51,11 @@ namespace Content.Shared.Interaction
[Dependency] private readonly IGameTiming _gameTiming = default!;
[Dependency] private readonly INetManager _net = default!;
[Dependency] private readonly IMapManager _mapManager = default!;
[Dependency] private readonly ISharedAdminManager _adminManager = default!;
[Dependency] private readonly ISharedAdminLogManager _adminLogger = default!;
[Dependency] private readonly ActionBlockerSystem _actionBlockerSystem = default!;
[Dependency] private readonly RotateToFaceSystem _rotateToFaceSystem = default!;
[Dependency] private readonly SharedContainerSystem _containerSystem = default!;
[Dependency] private readonly SharedPhysicsSystem _sharedBroadphaseSystem = default!;
[Dependency] private readonly SharedPhysicsSystem _broadphase = default!;
[Dependency] private readonly SharedTransformSystem _transform = default!;
[Dependency] private readonly SharedVerbSystem _verbSystem = default!;
[Dependency] private readonly SharedPopupSystem _popupSystem = default!;
@@ -64,6 +64,18 @@ namespace Content.Shared.Interaction
[Dependency] private readonly InventorySystem _inventory = default!;
[Dependency] private readonly IRobustRandom _random = default!;
[Dependency] private readonly TagSystem _tagSystem = default!;
[Dependency] private readonly SharedUserInterfaceSystem _ui = default!;
private EntityQuery<IgnoreUIRangeComponent> _ignoreUiRangeQuery;
private EntityQuery<FixturesComponent> _fixtureQuery;
private EntityQuery<ItemComponent> _itemQuery;
private EntityQuery<PhysicsComponent> _physicsQuery;
private EntityQuery<HandsComponent> _handsQuery;
private EntityQuery<InteractionRelayComponent> _relayQuery;
private EntityQuery<CombatModeComponent> _combatQuery;
private EntityQuery<WallMountComponent> _wallMountQuery;
private EntityQuery<UseDelayComponent> _delayQuery;
private EntityQuery<ActivatableUIComponent> _uiQuery;
private const CollisionGroup InRangeUnobstructedMask = CollisionGroup.Impassable | CollisionGroup.InteractImpassable;
@@ -76,6 +88,17 @@ namespace Content.Shared.Interaction
public override void Initialize()
{
_ignoreUiRangeQuery = GetEntityQuery<IgnoreUIRangeComponent>();
_fixtureQuery = GetEntityQuery<FixturesComponent>();
_itemQuery = GetEntityQuery<ItemComponent>();
_physicsQuery = GetEntityQuery<PhysicsComponent>();
_handsQuery = GetEntityQuery<HandsComponent>();
_relayQuery = GetEntityQuery<InteractionRelayComponent>();
_combatQuery = GetEntityQuery<CombatModeComponent>();
_wallMountQuery = GetEntityQuery<WallMountComponent>();
_delayQuery = GetEntityQuery<UseDelayComponent>();
_uiQuery = GetEntityQuery<ActivatableUIComponent>();
SubscribeLocalEvent<BoundUserInterfaceCheckRangeEvent>(HandleUserInterfaceRangeCheck);
SubscribeLocalEvent<BoundUserInterfaceMessageAttempt>(OnBoundInterfaceInteractAttempt);
@@ -111,29 +134,57 @@ namespace Content.Shared.Interaction
/// </summary>
private void OnBoundInterfaceInteractAttempt(BoundUserInterfaceMessageAttempt ev)
{
var user = ev.Actor;
_uiQuery.TryComp(ev.Target, out var uiComp);
if (!_actionBlockerSystem.CanInteract(ev.Actor, ev.Target))
{
// We permit ghosts to open uis unless explicitly blocked
if (ev.Message is not OpenBoundInterfaceMessage || !HasComp<GhostComponent>(ev.Actor) || uiComp?.BlockSpectators == true)
{
ev.Cancel();
return;
}
}
if (!_actionBlockerSystem.CanInteract(user, ev.Target))
var range = _ui.GetUiRange(ev.Target, ev.UiKey);
// As long as range>0, the UI frame updates should have auto-closed the UI if it is out of range.
DebugTools.Assert(range <= 0 || UiRangeCheck(ev.Actor, ev.Target, range));
if (range <= 0 && !IsAccessible(ev.Actor, ev.Target))
{
ev.Cancel();
return;
}
// Check if the bound entity is accessible. Note that we allow admins to ignore this restriction, so that
// they can fiddle with UI's that people can't normally interact with (e.g., placing things directly into
// other people's backpacks).
if (!_containerSystem.IsInSameOrParentContainer(user, ev.Target)
&& !CanAccessViaStorage(user, ev.Target)
&& !_adminManager.HasAdminFlag(user, AdminFlags.Admin))
if (uiComp == null)
return;
if (uiComp.SingleUser && uiComp.CurrentSingleUser != ev.Actor)
{
ev.Cancel();
return;
}
if (!InRangeUnobstructed(user, ev.Target))
{
if (!uiComp.RequireHands)
return;
if (!_handsQuery.TryComp(ev.Actor, out var hands) || hands.Hands.Count == 0)
ev.Cancel();
}
}
private bool UiRangeCheck(Entity<TransformComponent?> user, Entity<TransformComponent?> target, float range)
{
if (!Resolve(target, ref target.Comp))
return false;
if (user.Owner == target.Owner)
return true;
// Fast check: if the user is the parent of the entity (e.g., holding it), we always assume that it is in range
if (target.Comp.ParentUid == user.Owner)
return true;
return InRangeAndAccessible(user, target, range) || _ignoreUiRangeQuery.HasComp(user);
}
/// <summary>
@@ -190,10 +241,7 @@ namespace Content.Shared.Interaction
if (!InRangeUnobstructed(userEntity.Value, uid, popup: true))
return false;
if (!TryComp(uid, out PullableComponent? pull))
return false;
_pullSystem.TogglePull(uid, userEntity.Value, pull);
_pullSystem.TogglePull(uid, userEntity.Value);
return false;
}
@@ -269,7 +317,7 @@ namespace Content.Shared.Interaction
public bool CombatModeCanHandInteract(EntityUid user, EntityUid? target)
{
// Always allow attack in these cases
if (target == null || !TryComp<HandsComponent>(user, out var hands) || hands.ActiveHand?.HeldEntity is not null)
if (target == null || !_handsQuery.TryComp(user, out var hands) || hands.ActiveHand?.HeldEntity is not null)
return false;
// Only eat input if:
@@ -277,7 +325,7 @@ namespace Content.Shared.Interaction
// - Target doesn't cancel should-interact event
// This is intended to allow items to be picked up in combat mode,
// but to also allow items to force attacks anyway (like mobs which are items, e.g. mice)
if (!HasComp<ItemComponent>(target))
if (!_itemQuery.HasComp(target))
return false;
var combatEv = new CombatModeShouldHandInteractEvent();
@@ -307,7 +355,7 @@ namespace Content.Shared.Interaction
bool checkAccess = true,
bool checkCanUse = true)
{
if (TryComp<InteractionRelayComponent>(user, out var relay) && relay.RelayEntity is not null)
if (_relayQuery.TryComp(user, out var relay) && relay.RelayEntity is not null)
{
// TODO this needs to be handled better. This probably bypasses many complex can-interact checks in weird roundabout ways.
if (_actionBlockerSystem.CanInteract(user, target))
@@ -321,7 +369,7 @@ namespace Content.Shared.Interaction
if (target != null && Deleted(target.Value))
return;
if (!altInteract && TryComp<CombatModeComponent>(user, out var combatMode) && combatMode.IsInCombatMode)
if (!altInteract && _combatQuery.TryComp(user, out var combatMode) && combatMode.IsInCombatMode)
{
if (!CombatModeCanHandInteract(user, target))
return;
@@ -343,10 +391,7 @@ namespace Content.Shared.Interaction
// Check if interacted entity is in the same container, the direct child, or direct parent of the user.
// Also checks if the item is accessible via some storage UI (e.g., open backpack)
if (checkAccess
&& target != null
&& !_containerSystem.IsInSameOrParentContainer(user, target.Value)
&& !CanAccessViaStorage(user, target.Value))
if (checkAccess && target != null && !IsAccessible(user, target.Value))
return;
var inRangeUnobstructed = target == null
@@ -354,7 +399,7 @@ namespace Content.Shared.Interaction
: !checkAccess || InRangeUnobstructed(user, target.Value); // permits interactions with wall mounted entities
// Does the user have hands?
if (!TryComp<HandsComponent>(user, out var hands) || hands.ActiveHand == null)
if (!_handsQuery.TryComp(user, out var hands) || hands.ActiveHand == null)
{
var ev = new InteractNoHandEvent(user, target, coordinates);
RaiseLocalEvent(user, ev);
@@ -494,7 +539,7 @@ namespace Content.Shared.Interaction
predicate ??= _ => false;
var ray = new CollisionRay(origin.Position, dir.Normalized(), collisionMask);
var rayResults = _sharedBroadphaseSystem.IntersectRayWithPredicate(origin.MapId, ray, dir.Length(), predicate.Invoke, false).ToList();
var rayResults = _broadphase.IntersectRayWithPredicate(origin.MapId, ray, dir.Length(), predicate.Invoke, false).ToList();
if (rayResults.Count == 0)
return dir.Length();
@@ -557,23 +602,29 @@ namespace Content.Shared.Interaction
}
var ray = new CollisionRay(origin.Position, dir.Normalized(), (int) collisionMask);
var rayResults = _sharedBroadphaseSystem.IntersectRayWithPredicate(origin.MapId, ray, length, predicate.Invoke, false).ToList();
var rayResults = _broadphase.IntersectRayWithPredicate(origin.MapId, ray, length, predicate.Invoke, false).ToList();
return rayResults.Count == 0;
}
public bool InRangeUnobstructed(
EntityUid origin,
EntityUid other,
Entity<TransformComponent?> origin,
Entity<TransformComponent?> other,
float range = InteractionRange,
CollisionGroup collisionMask = InRangeUnobstructedMask,
Ignored? predicate = null,
bool popup = false)
{
if (!TryComp(other, out TransformComponent? otherXform))
if (!Resolve(other, ref other.Comp))
return false;
return InRangeUnobstructed(origin, other, otherXform.Coordinates, otherXform.LocalRotation, range, collisionMask, predicate,
return InRangeUnobstructed(origin,
other,
other.Comp.Coordinates,
other.Comp.LocalRotation,
range,
collisionMask,
predicate,
popup);
}
@@ -605,8 +656,8 @@ namespace Content.Shared.Interaction
/// True if the two points are within a given range without being obstructed.
/// </returns>
public bool InRangeUnobstructed(
EntityUid origin,
EntityUid other,
Entity<TransformComponent?> origin,
Entity<TransformComponent?> other,
EntityCoordinates otherCoordinates,
Angle otherAngle,
float range = InteractionRange,
@@ -614,10 +665,10 @@ namespace Content.Shared.Interaction
Ignored? predicate = null,
bool popup = false)
{
Ignored combinedPredicate = e => e == origin || (predicate?.Invoke(e) ?? false);
Ignored combinedPredicate = e => e == origin.Owner || (predicate?.Invoke(e) ?? false);
var inRange = true;
MapCoordinates originPos = default;
var targetPos = otherCoordinates.ToMap(EntityManager, _transform);
var targetPos = _transform.ToMapCoordinates(otherCoordinates);
Angle targetRot = default;
// So essentially:
@@ -627,23 +678,30 @@ namespace Content.Shared.Interaction
// Alternatively we could check centre distances first though
// that means we wouldn't be able to easily check overlap interactions.
if (range > 0f &&
TryComp<FixturesComponent>(origin, out var fixtureA) &&
_fixtureQuery.TryComp(origin, out var fixtureA) &&
// These fixture counts are stuff that has the component but no fixtures for <reasons> (e.g. buttons).
// At least until they get removed.
fixtureA.FixtureCount > 0 &&
TryComp<FixturesComponent>(other, out var fixtureB) &&
_fixtureQuery.TryComp(other, out var fixtureB) &&
fixtureB.FixtureCount > 0 &&
TryComp(origin, out TransformComponent? xformA))
Resolve(origin, ref origin.Comp))
{
var (worldPosA, worldRotA) = xformA.GetWorldPositionRotation();
var (worldPosA, worldRotA) = origin.Comp.GetWorldPositionRotation();
var xfA = new Transform(worldPosA, worldRotA);
var parentRotB = _transform.GetWorldRotation(otherCoordinates.EntityId);
var xfB = new Transform(targetPos.Position, parentRotB + otherAngle);
// Different map or the likes.
if (!_sharedBroadphaseSystem.TryGetNearest(origin, other,
out _, out _, out var distance,
xfA, xfB, fixtureA, fixtureB))
if (!_broadphase.TryGetNearest(
origin,
other,
out _,
out _,
out var distance,
xfA,
xfB,
fixtureA,
fixtureB))
{
inRange = false;
}
@@ -665,15 +723,15 @@ namespace Content.Shared.Interaction
else
{
// We'll still do the raycast from the centres but we'll bump the range as we know they're in range.
originPos = _transform.GetMapCoordinates(origin, xform: xformA);
originPos = _transform.GetMapCoordinates(origin, xform: origin.Comp);
range = (originPos.Position - targetPos.Position).Length();
}
}
// No fixtures, e.g. wallmounts.
else
{
originPos = _transform.GetMapCoordinates(origin);
var otherParent = Transform(other).ParentUid;
originPos = _transform.GetMapCoordinates(origin, origin);
var otherParent = (other.Comp ?? Transform(other)).ParentUid;
targetRot = otherParent.IsValid() ? Transform(otherParent).LocalRotation + otherAngle : otherAngle;
}
@@ -724,13 +782,13 @@ namespace Content.Shared.Interaction
{
HashSet<EntityUid> ignored = new();
if (HasComp<ItemComponent>(target) && TryComp(target, out PhysicsComponent? physics) && physics.CanCollide)
if (_itemQuery.HasComp(target) && _physicsQuery.TryComp(target, out var physics) && physics.CanCollide)
{
// If the target is an item, we ignore any colliding entities. Currently done so that if items get stuck
// inside of walls, users can still pick them up.
ignored.UnionWith(_sharedBroadphaseSystem.GetEntitiesIntersectingBody(target, (int) collisionMask, false, physics));
ignored.UnionWith(_broadphase.GetEntitiesIntersectingBody(target, (int) collisionMask, false, physics));
}
else if (TryComp(target, out WallMountComponent? wallMount))
else if (_wallMountQuery.TryComp(target, out var wallMount))
{
// wall-mount exemptions may be restricted to a specific angle range.da
@@ -748,13 +806,7 @@ namespace Content.Shared.Interaction
ignored.UnionWith(grid.GetAnchoredEntities(targetCoords));
}
Ignored combinedPredicate = e =>
{
return e == target
|| (predicate?.Invoke(e) ?? false)
|| ignored.Contains(e);
};
Ignored combinedPredicate = e => e == target || (predicate?.Invoke(e) ?? false) || ignored.Contains(e);
return combinedPredicate;
}
@@ -951,10 +1003,8 @@ namespace Content.Shared.Interaction
bool checkUseDelay = true,
bool checkAccess = true)
{
UseDelayComponent? delayComponent = null;
if (checkUseDelay
&& TryComp(used, out delayComponent)
&& _useDelay.IsDelayed((used, delayComponent)))
_delayQuery.TryComp(used, out var delayComponent);
if (checkUseDelay && delayComponent != null && _useDelay.IsDelayed((used, delayComponent)))
return false;
if (checkCanInteract && !_actionBlockerSystem.CanInteract(user, used))
@@ -965,11 +1015,11 @@ namespace Content.Shared.Interaction
// Check if interacted entity is in the same container, the direct child, or direct parent of the user.
// This is bypassed IF the interaction happened through an item slot (e.g., backpack UI)
if (checkAccess && !_containerSystem.IsInSameOrParentContainer(user, used) && !CanAccessViaStorage(user, used))
if (checkAccess && !IsAccessible(user, used))
return false;
// Does the user have hands?
if (!HasComp<HandsComponent>(user))
if (!_handsQuery.HasComp(user))
return false;
var activateMsg = new ActivateInWorldEvent(user, used);
@@ -979,7 +1029,9 @@ namespace Content.Shared.Interaction
DoContactInteraction(user, used, activateMsg);
// Still need to call this even without checkUseDelay in case this gets relayed from Activate.
_useDelay.TryResetDelay(used, component: delayComponent);
if (delayComponent != null)
_useDelay.TryResetDelay(used, component: delayComponent);
if (!activateMsg.WasLogged)
_adminLogger.Add(LogType.InteractActivate, LogImpact.Low, $"{ToPrettyString(user):user} activated {ToPrettyString(used):used}");
return true;
@@ -1000,11 +1052,8 @@ namespace Content.Shared.Interaction
bool checkCanInteract = true,
bool checkUseDelay = true)
{
UseDelayComponent? delayComponent = null;
if (checkUseDelay
&& TryComp(used, out delayComponent)
&& _useDelay.IsDelayed((used, delayComponent)))
_delayQuery.TryComp(used, out var delayComponent);
if (checkUseDelay && delayComponent != null && _useDelay.IsDelayed((used, delayComponent)))
return true; // if the item is on cooldown, we consider this handled.
if (checkCanInteract && !_actionBlockerSystem.CanInteract(user, used))
@@ -1066,11 +1115,60 @@ namespace Content.Shared.Interaction
}
#endregion
/// <summary>
/// Check if a user can access a target (stored in the same containers) and is in range without obstructions.
/// </summary>
public bool InRangeAndAccessible(
Entity<TransformComponent?> user,
Entity<TransformComponent?> target,
float range = InteractionRange,
CollisionGroup collisionMask = InRangeUnobstructedMask,
Ignored? predicate = null)
{
if (user == target)
return true;
if (!Resolve(user, ref user.Comp))
return false;
if (!Resolve(target, ref target.Comp))
return false;
return IsAccessible(user, target) && InRangeUnobstructed(user, target, range, collisionMask, predicate);
}
/// <summary>
/// Check if a user can access a target or if they are stored in different containers.
/// </summary>
public bool IsAccessible(Entity<TransformComponent?> user, Entity<TransformComponent?> target)
{
if (_containerSystem.IsInSameOrParentContainer(user, target, out _, out var container))
return true;
return container != null && CanAccessViaStorage(user, target, container);
}
/// <summary>
/// If a target is in range, but not in the same container as the user, it may be inside of a backpack. This
/// checks if the user can access the item in these situations.
/// </summary>
public abstract bool CanAccessViaStorage(EntityUid user, EntityUid target);
public bool CanAccessViaStorage(EntityUid user, EntityUid target)
{
if (!_containerSystem.TryGetContainingContainer(target, out var container))
return false;
return CanAccessViaStorage(user, target, container);
}
/// <inheritdoc cref="CanAccessViaStorage(Robust.Shared.GameObjects.EntityUid,Robust.Shared.GameObjects.EntityUid)"/>
public bool CanAccessViaStorage(EntityUid user, EntityUid target, BaseContainer container)
{
if (StorageComponent.ContainerId != container.ID)
return false;
// we don't check if the user can access the storage entity itself. This should be handed by the UI system.
return _ui.IsUiOpen(target, StorageComponent.StorageUiKey.Key, user);
}
/// <summary>
/// Checks whether an entity currently equipped by another player is accessible to some user. This shouldn't
@@ -1151,19 +1249,15 @@ namespace Content.Shared.Interaction
RaiseLocalEvent(uidB.Value, new ContactInteractionEvent(uidA));
}
private void HandleUserInterfaceRangeCheck(ref BoundUserInterfaceCheckRangeEvent ev)
{
if (ev.Result == BoundUserInterfaceRangeResult.Fail)
return;
if (InRangeUnobstructed(ev.Actor, ev.Target, ev.Data.InteractionRange))
{
ev.Result = BoundUserInterfaceRangeResult.Pass;
}
else
{
ev.Result = BoundUserInterfaceRangeResult.Fail;
}
ev.Result = UiRangeCheck(ev.Actor!, ev.Target, ev.Data.InteractionRange)
? BoundUserInterfaceRangeResult.Pass
: BoundUserInterfaceRangeResult.Fail;
}
}