using System.Diagnostics.CodeAnalysis;
using System.Linq;
using Content.Shared.ActionBlocker;
using Content.Shared.Administration.Logs;
using Content.Shared.CCVar;
using Content.Shared.Chat;
using Content.Shared.CombatMode;
using Content.Shared.Database;
using Content.Shared.Ghost;
using Content.Shared.Hands;
using Content.Shared.Hands.Components;
using Content.Shared.Hands.EntitySystems;
using Content.Shared.Input;
using Content.Shared.Interaction.Components;
using Content.Shared.Interaction.Events;
using Content.Shared.Inventory;
using Content.Shared.Inventory.Events;
using Content.Shared.Item;
using Content.Shared.Movement.Components;
using Content.Shared.Movement.Pulling.Systems;
using Content.Shared.Movement.Systems;
using Content.Shared.Physics;
using Content.Shared.Players.RateLimiting;
using Content.Shared.Popups;
using Content.Shared.Storage;
using Content.Shared.Strip;
using Content.Shared.Tag;
using Content.Shared.Timing;
using Content.Shared.UserInterface;
using Content.Shared.Verbs;
using Content.Shared.Wall;
using JetBrains.Annotations;
using Robust.Shared.Containers;
using Robust.Shared.Input;
using Robust.Shared.Input.Binding;
using Robust.Shared.Map;
using Robust.Shared.Network;
using Robust.Shared.Physics;
using Robust.Shared.Physics.Collision.Shapes;
using Robust.Shared.Physics.Components;
using Robust.Shared.Physics.Systems;
using Robust.Shared.Player;
using Robust.Shared.Prototypes;
using Robust.Shared.Serialization;
using Robust.Shared.Timing;
using Robust.Shared.Utility;
namespace Content.Shared.Interaction
{
///
/// Governs interactions during clicking on entities
///
[UsedImplicitly]
public abstract partial class SharedInteractionSystem : EntitySystem
{
[Dependency] private readonly IGameTiming _gameTiming = default!;
[Dependency] private readonly IMapManager _mapManager = default!;
[Dependency] private readonly ISharedAdminLogManager _adminLogger = default!;
[Dependency] private readonly ISharedChatManager _chat = default!;
[Dependency] private readonly ActionBlockerSystem _actionBlockerSystem = default!;
[Dependency] private readonly EntityLookupSystem _lookup = default!;
[Dependency] private readonly SharedHandsSystem _hands = default!;
[Dependency] private readonly InventorySystem _inventory = default!;
[Dependency] private readonly PullingSystem _pullSystem = default!;
[Dependency] private readonly RotateToFaceSystem _rotateToFaceSystem = default!;
[Dependency] private readonly SharedContainerSystem _containerSystem = default!;
[Dependency] private readonly SharedMapSystem _map = 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!;
[Dependency] private readonly SharedUserInterfaceSystem _ui = default!;
[Dependency] private readonly SharedStrippableSystem _strippable = default!;
[Dependency] private readonly SharedPlayerRateLimitManager _rateLimit = default!;
[Dependency] private readonly TagSystem _tagSystem = default!;
[Dependency] private readonly UseDelaySystem _useDelay = default!;
private EntityQuery _ignoreUiRangeQuery;
private EntityQuery _fixtureQuery;
private EntityQuery _itemQuery;
private EntityQuery _physicsQuery;
private EntityQuery _handsQuery;
private EntityQuery _relayQuery;
private EntityQuery _combatQuery;
private EntityQuery _wallMountQuery;
private EntityQuery _delayQuery;
private EntityQuery _uiQuery;
///
/// The collision mask used by default for
///
///
public const CollisionGroup InRangeUnobstructedMask = CollisionGroup.Impassable | CollisionGroup.InteractImpassable;
public const float InteractionRange = 1.5f;
public const float InteractionRangeSquared = InteractionRange * InteractionRange;
public const float MaxRaycastRange = 100f;
public const string RateLimitKey = "Interaction";
private static readonly ProtoId BypassInteractionRangeChecksTag = "BypassInteractionRangeChecks";
public delegate bool Ignored(EntityUid entity);
public override void Initialize()
{
_ignoreUiRangeQuery = GetEntityQuery();
_fixtureQuery = GetEntityQuery();
_itemQuery = GetEntityQuery();
_physicsQuery = GetEntityQuery();
_handsQuery = GetEntityQuery();
_relayQuery = GetEntityQuery();
_combatQuery = GetEntityQuery();
_wallMountQuery = GetEntityQuery();
_delayQuery = GetEntityQuery();
_uiQuery = GetEntityQuery();
SubscribeLocalEvent(HandleUserInterfaceRangeCheck);
// TODO make this a broadcast event subscription again when engine has updated.
SubscribeLocalEvent(OnBoundInterfaceInteractAttempt);
SubscribeAllEvent(HandleInteractInventorySlotEvent);
SubscribeLocalEvent(OnRemoveAttempt);
SubscribeLocalEvent(OnUnequip);
SubscribeLocalEvent(OnUnequipHand);
SubscribeLocalEvent(OnDropped);
CommandBinds.Builder
.Bind(ContentKeyFunctions.AltActivateItemInWorld,
new PointerInputCmdHandler(HandleAltUseInteraction))
.Bind(EngineKeyFunctions.Use,
new PointerInputCmdHandler(HandleUseInteraction))
.Bind(ContentKeyFunctions.ActivateItemInWorld,
new PointerInputCmdHandler(HandleActivateItemInWorld))
.Bind(ContentKeyFunctions.TryPullObject,
new PointerInputCmdHandler(HandleTryPullObject))
.Register();
_rateLimit.Register(RateLimitKey,
new RateLimitRegistration(CCVars.InteractionRateLimitPeriod,
CCVars.InteractionRateLimitCount,
null,
CCVars.InteractionRateLimitAnnounceAdminsDelay,
RateLimitAlertAdmins)
);
InitializeBlocking();
}
private void RateLimitAlertAdmins(ICommonSession session)
{
_chat.SendAdminAlert(Loc.GetString("interaction-rate-limit-admin-announcement", ("player", session.Name)));
}
public override void Shutdown()
{
CommandBinds.Unregister();
base.Shutdown();
}
///
/// Check that the user that is interacting with the BUI is capable of interacting and can access the entity.
///
private void OnBoundInterfaceInteractAttempt(Entity ent, ref BoundUserInterfaceMessageAttempt ev)
{
_uiQuery.TryComp(ev.Target, out var aUiComp);
if (!_actionBlockerSystem.CanInteract(ev.Actor, ev.Target))
{
// We permit ghosts to open uis unless explicitly blocked
if (ev.Message is not OpenBoundInterfaceMessage
|| !HasComp(ev.Actor)
|| aUiComp?.BlockSpectators == true)
{
ev.Cancel();
return;
}
}
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;
}
if (aUiComp == null)
return;
if (aUiComp.SingleUser && aUiComp.CurrentSingleUser != null && aUiComp.CurrentSingleUser != ev.Actor)
{
ev.Cancel();
return;
}
if (aUiComp.RequiresComplex && !_actionBlockerSystem.CanComplexInteract(ev.Actor))
ev.Cancel();
}
private bool UiRangeCheck(Entity user, Entity 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);
}
///
/// Prevents an item with the Unremovable component from being removed from a container by almost any means
///
private void OnRemoveAttempt(EntityUid uid, UnremoveableComponent item, ContainerGettingRemovedAttemptEvent args)
{
// don't prevent the server state for the container from being applied to the client correctly
// otherwise this will cause an error if the client predicts adding UnremoveableComponent
if (!_gameTiming.ApplyingState)
args.Cancel();
}
///
/// If item has DeleteOnDrop true then item will be deleted if removed from inventory, if it is false then item
/// loses Unremoveable when removed from inventory (gibbing).
///
private void OnUnequip(EntityUid uid, UnremoveableComponent item, GotUnequippedEvent args)
{
if (!item.DeleteOnDrop)
RemCompDeferred(uid);
else
PredictedQueueDel(uid);
}
private void OnUnequipHand(EntityUid uid, UnremoveableComponent item, GotUnequippedHandEvent args)
{
if (!item.DeleteOnDrop)
RemCompDeferred(uid);
else
PredictedQueueDel(uid);
}
private void OnDropped(EntityUid uid, UnremoveableComponent item, DroppedEvent args)
{
if (!item.DeleteOnDrop)
RemCompDeferred(uid);
else
PredictedQueueDel(uid);
}
private bool HandleTryPullObject(ICommonSession? session, EntityCoordinates coords, EntityUid uid)
{
if (!ValidateClientInput(session, coords, uid, out var userEntity))
{
Log.Info($"TryPullObject input validation failed");
return true;
}
//is this user trying to pull themself?
if (userEntity.Value == uid)
return false;
if (Deleted(uid))
return false;
if (!InRangeUnobstructed(userEntity.Value, uid, popup: true))
return false;
_pullSystem.TogglePull(uid, userEntity.Value);
return false;
}
///
/// Handles the event were a client uses an item in their inventory or in their hands, either by
/// alt-clicking it or pressing 'E' while hovering over it.
///
private void HandleInteractInventorySlotEvent(InteractInventorySlotEvent msg, EntitySessionEventArgs args)
{
var item = GetEntity(msg.ItemUid);
// client sanitization
if (!TryComp(item, out TransformComponent? itemXform) || !ValidateClientInput(args.SenderSession, itemXform.Coordinates, item, out var user))
{
Log.Info($"Inventory interaction validation failed. Session={args.SenderSession}");
return;
}
// We won't bother to check that the target item is ACTUALLY in an inventory slot. UserInteraction() and
// InteractionActivate() should check that the item is accessible. So.. if a user wants to lie about an
// in-reach item being used in a slot... that should have no impact. This is functionally the same as if
// they had somehow directly clicked on that item.
if (msg.AltInteract)
// Use 'UserInteraction' function - behaves as if the user alt-clicked the item in the world.
UserInteraction(user.Value, itemXform.Coordinates, item, msg.AltInteract);
else
// User used 'E'. We want to activate it, not simulate clicking on the item
InteractionActivate(user.Value, item);
}
public bool HandleAltUseInteraction(ICommonSession? session, EntityCoordinates coords, EntityUid uid)
{
// client sanitization
if (!ValidateClientInput(session, coords, uid, out var user))
{
Log.Info($"Alt-use input validation failed");
return true;
}
UserInteraction(user.Value, coords, uid, altInteract: true, checkAccess: ShouldCheckAccess(user.Value));
return false;
}
public bool HandleUseInteraction(ICommonSession? session, EntityCoordinates coords, EntityUid uid)
{
// client sanitization
if (!ValidateClientInput(session, coords, uid, out var userEntity))
{
Log.Info($"Use input validation failed");
return true;
}
UserInteraction(userEntity.Value, coords, !Deleted(uid) ? uid : null, checkAccess: ShouldCheckAccess(userEntity.Value));
return false;
}
private bool ShouldCheckAccess(EntityUid user)
{
// This is for Admin/mapping convenience. If ever there are other ghosts that can still interact, this check
// might need to be more selective.
return !_tagSystem.HasTag(user, BypassInteractionRangeChecksTag);
}
///
/// Returns true if the specified entity should hand interact with the target instead of attacking
///
/// The user interacting in combat mode
/// The target of the interaction
///
public bool CombatModeCanHandInteract(EntityUid user, EntityUid? target)
{
// Always allow attack in these cases
if (target == null || !_handsQuery.TryComp(user, out var hands) || _hands.GetActiveItem((user, hands)) is not null)
return false;
// Only eat input if:
// - Target isn't an item
// - 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 (!_itemQuery.HasComp(target))
return false;
var combatEv = new CombatModeShouldHandInteractEvent();
RaiseLocalEvent(target.Value, ref combatEv);
if (combatEv.Cancelled)
return false;
return true;
}
///
/// Resolves user interactions with objects.
///
///
/// Checks Whether combat mode is enabled and whether the user can actually interact with the given entity.
///
/// Whether to use default or alternative interactions (usually as a result of
/// alt+clicking). If combat mode is enabled, the alternative action is to perform the default non-combat
/// interaction. Having an item in the active hand also disables alternative interactions.
public void UserInteraction(
EntityUid user,
EntityCoordinates coordinates,
EntityUid? target,
bool altInteract = false,
bool checkCanInteract = true,
bool checkAccess = true,
bool checkCanUse = true)
{
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))
{
UserInteraction(relay.RelayEntity.Value,
coordinates,
target,
altInteract,
checkCanInteract,
checkAccess,
checkCanUse);
return;
}
}
if (target != null && Deleted(target.Value))
return;
if (!altInteract && _combatQuery.TryComp(user, out var combatMode) && combatMode.IsInCombatMode)
{
if (!CombatModeCanHandInteract(user, target))
return;
}
if (!ValidateInteractAndFace(user, coordinates))
return;
if (altInteract && target != null)
{
// Perform alternative interactions, using context menu verbs.
// These perform their own range, can-interact, and accessibility checks.
AltInteract(user, target.Value);
return;
}
if (checkCanInteract && !_actionBlockerSystem.CanInteract(user, target))
return;
// 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 && !IsAccessible(user, target.Value))
return;
var inRangeUnobstructed = target == null
? !checkAccess || InRangeUnobstructed(user, coordinates)
: !checkAccess || InRangeUnobstructed(user, target.Value); // permits interactions with wall mounted entities
// empty-hand interactions
// combat mode hand interactions will always be true here -- since
// they check this earlier before returning in
if (!TryGetUsedEntity(user, out var used, checkCanUse))
{
if (inRangeUnobstructed && target != null)
InteractHand(user, target.Value);
return;
}
if (target == used)
{
UseInHandInteraction(user, target.Value, checkCanUse: false, checkCanInteract: false);
return;
}
if (inRangeUnobstructed && target != null)
{
InteractUsing(
user,
used.Value,
target.Value,
coordinates,
checkCanInteract: false,
checkCanUse: false);
return;
}
InteractUsingRanged(
user,
used.Value,
target,
coordinates,
inRangeUnobstructed);
}
private bool IsDeleted(EntityUid uid)
{
return TerminatingOrDeleted(uid) || EntityManager.IsQueuedForDeletion(uid);
}
private bool IsDeleted(EntityUid? uid)
{
//optional / null entities can pass this validation check. I.e., is-deleted returns false for null uids
return uid != null && IsDeleted(uid.Value);
}
public void InteractHand(EntityUid user, EntityUid target)
{
if (IsDeleted(user) || IsDeleted(target))
return;
var complexInteractions = _actionBlockerSystem.CanComplexInteract(user);
if (!complexInteractions)
{
InteractionActivate(user,
target,
checkCanInteract: false,
checkUseDelay: true,
checkAccess: false,
complexInteractions: complexInteractions,
checkDeletion: false);
return;
}
// allow for special logic before main interaction
var ev = new BeforeInteractHandEvent(target);
RaiseLocalEvent(user, ev);
if (ev.Handled)
{
_adminLogger.Add(LogType.InteractHand, LogImpact.Low, $"{ToPrettyString(user):user} interacted with {ToPrettyString(target):target}, but it was handled by another system");
return;
}
DebugTools.Assert(!IsDeleted(user) && !IsDeleted(target));
// all interactions should only happen when in range / unobstructed, so no range check is needed
var message = new InteractHandEvent(user, target);
RaiseLocalEvent(target, message, true);
_adminLogger.Add(LogType.InteractHand, LogImpact.Low, $"{ToPrettyString(user):user} interacted with {ToPrettyString(target):target}");
DoContactInteraction(user, target, message);
if (message.Handled)
return;
DebugTools.Assert(!IsDeleted(user) && !IsDeleted(target));
// Else we run Activate.
InteractionActivate(user,
target,
checkCanInteract: false,
checkUseDelay: true,
checkAccess: false,
complexInteractions: complexInteractions,
checkDeletion: false);
}
public void InteractUsingRanged(EntityUid user, EntityUid used, EntityUid? target,
EntityCoordinates clickLocation, bool inRangeUnobstructed)
{
if (IsDeleted(user) || IsDeleted(used) || IsDeleted(target))
return;
if (target != null)
{
_adminLogger.Add(
LogType.InteractUsing,
LogImpact.Low,
$"{ToPrettyString(user):user} interacted with {ToPrettyString(target):target} using {ToPrettyString(used):used}");
}
else
{
_adminLogger.Add(
LogType.InteractUsing,
LogImpact.Low,
$"{ToPrettyString(user):user} interacted with *nothing* using {ToPrettyString(used):used}");
}
if (RangedInteractDoBefore(user, used, target, clickLocation, inRangeUnobstructed, checkDeletion: false))
return;
DebugTools.Assert(!IsDeleted(user) && !IsDeleted(used) && !IsDeleted(target));
if (target != null)
{
var rangedMsg = new RangedInteractEvent(user, used, target.Value, clickLocation);
RaiseLocalEvent(target.Value, rangedMsg, true);
// We contact the USED entity, but not the target.
DoContactInteraction(user, used, rangedMsg);
if (rangedMsg.Handled)
return;
}
DebugTools.Assert(!IsDeleted(user) && !IsDeleted(used) && !IsDeleted(target));
InteractDoAfter(user, used, target, clickLocation, inRangeUnobstructed, checkDeletion: false);
}
protected bool ValidateInteractAndFace(EntityUid user, EntityCoordinates coordinates)
{
// Verify user is on the same map as the entity they clicked on
if (_transform.GetMapId(coordinates) != Transform(user).MapID)
return false;
// Only rotate to face if they're not moving.
if (!HasComp(user) && (!TryComp(user, out InputMoverComponent? mover) || (mover.HeldMoveButtons & MoveButtons.AnyDirection) == 0x0))
{
_rotateToFaceSystem.TryFaceCoordinates(user, _transform.ToMapCoordinates(coordinates).Position);
}
return true;
}
///
/// Traces a ray from coords to otherCoords and returns the length
/// of the vector between coords and the ray's first hit.
///
/// Set of coordinates to use.
/// Other set of coordinates to use.
/// the mask to check for collisions
///
/// A predicate to check whether to ignore an entity or not.
/// If it returns true, it will be ignored.
///
/// Length of resulting ray.
public float UnobstructedDistance(
MapCoordinates origin,
MapCoordinates other,
int collisionMask = (int) InRangeUnobstructedMask,
Ignored? predicate = null)
{
var dir = other.Position - origin.Position;
if (dir.LengthSquared().Equals(0f))
return 0f;
predicate ??= _ => false;
var ray = new CollisionRay(origin.Position, dir.Normalized(), collisionMask);
var rayResults = _broadphase.IntersectRayWithPredicate(origin.MapId, ray, dir.Length(), predicate.Invoke, false).ToList();
if (rayResults.Count == 0)
return dir.Length();
return (rayResults[0].HitPos - origin.Position).Length();
}
///
/// Checks that these coordinates are within a certain distance without any
/// entity that matches the collision mask obstructing them.
/// If the is zero or negative,
/// this method will only check if nothing obstructs the two sets
/// of coordinates.
///
/// Set of coordinates to use.
/// Other set of coordinates to use.
///
/// Maximum distance between the two sets of coordinates.
///
/// The mask to check for collisions.
///
/// A predicate to check whether to ignore an entity or not.
/// If it returns true, it will be ignored.
///
/// Perform range checks
///
/// True if the two points are within a given range without being obstructed.
///
public bool InRangeUnobstructed(
MapCoordinates origin,
MapCoordinates other,
float range = InteractionRange,
CollisionGroup collisionMask = InRangeUnobstructedMask,
Ignored? predicate = null,
bool checkAccess = true)
{
// Have to be on same map regardless.
if (other.MapId != origin.MapId)
return false;
if (!checkAccess)
return true;
var dir = other.Position - origin.Position;
var length = dir.Length();
// If range specified also check it
if (range > 0f && length > range)
return false;
if (MathHelper.CloseTo(length, 0))
return true;
predicate ??= _ => false;
if (length > MaxRaycastRange)
{
Log.Warning("InRangeUnobstructed check performed over extreme range. Limiting CollisionRay size.");
length = MaxRaycastRange;
}
var ray = new CollisionRay(origin.Position, dir.Normalized(), (int) collisionMask);
var rayResults = _broadphase.IntersectRayWithPredicate(origin.MapId, ray, length, predicate.Invoke, false).ToList();
return rayResults.Count == 0;
}
public bool InRangeUnobstructed(
Entity origin,
Entity other,
float range = InteractionRange,
CollisionGroup collisionMask = InRangeUnobstructedMask,
Ignored? predicate = null,
bool popup = false,
bool overlapCheck = true)
{
if (!Resolve(other, ref other.Comp))
return false;
var ev = new InRangeOverrideEvent(origin, other);
RaiseLocalEvent(origin, ref ev);
if (ev.Handled)
{
return ev.InRange;
}
return InRangeUnobstructed(origin,
other,
other.Comp.Coordinates,
other.Comp.LocalRotation,
range,
collisionMask,
predicate,
popup,
overlapCheck);
}
///
/// Checks that two entities are within a certain distance without any
/// entity that matches the collision mask obstructing them.
/// If the is zero or negative,
/// this method will only check if nothing obstructs the two entities.
/// This function will also check whether the other entity is a wall-mounted entity. If it is, it will
/// automatically ignore some obstructions.
///
/// The first entity to use.
/// Other entity to use.
/// The local rotation to use for the other entity.
///
/// Maximum distance between the two entities.
///
/// The mask to check for collisions.
///
/// A predicate to check whether to ignore an entity or not.
/// If it returns true, it will be ignored.
///
///
/// Whether or not to popup a feedback message on the origin entity for
/// it to see.
///
/// The coordinates to use for the other entity.
///
/// True if the two points are within a given range without being obstructed.
///
/// If true, if the broadphase query returns an overlap (0f distance) this function will early out true with no raycast made.
public bool InRangeUnobstructed(
Entity origin,
Entity other,
EntityCoordinates otherCoordinates,
Angle otherAngle,
float range = InteractionRange,
CollisionGroup collisionMask = InRangeUnobstructedMask,
Ignored? predicate = null,
bool popup = false,
bool overlapCheck = true)
{
Ignored combinedPredicate = e => e == origin.Owner || (predicate?.Invoke(e) ?? false);
var inRange = true;
MapCoordinates originPos = default;
var targetPos = _transform.ToMapCoordinates(otherCoordinates);
Angle targetRot = _transform.GetWorldRotation(otherCoordinates.EntityId) + otherAngle;
// So essentially:
// 1. If fixtures available check nearest point. We take in coordinates / angles because we might want to use a lag compensated position
// 2. Fall back to centre of body.
// Alternatively we could check centre distances first though
// that means we wouldn't be able to easily check overlap interactions.
if (range > 0f &&
_fixtureQuery.TryComp(origin, out var fixtureA) &&
// These fixture counts are stuff that has the component but no fixtures for (e.g. buttons).
// At least until they get removed.
fixtureA.FixtureCount > 0 &&
_fixtureQuery.TryComp(other, out var fixtureB) &&
fixtureB.FixtureCount > 0 &&
Resolve(origin, ref origin.Comp))
{
var (worldPosA, worldRotA) = _transform.GetWorldPositionRotation(origin.Comp);
var xfA = new Transform(worldPosA, worldRotA);
var xfB = new Transform(targetPos.Position, targetRot);
// Different map or the likes.
if (!_broadphase.TryGetNearest(
origin,
other,
out _,
out _,
out var distance,
xfA,
xfB,
fixtureA,
fixtureB))
{
inRange = false;
}
// Overlap, early out and no raycast.
else if (overlapCheck && distance.Equals(0f))
{
return true;
}
// Entity can bypass range checks.
else if (!ShouldCheckAccess(origin))
{
return true;
}
// Out of range so don't raycast.
else if (distance > range)
{
inRange = false;
}
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: origin.Comp);
range = (originPos.Position - targetPos.Position).Length();
}
}
// No fixtures, e.g. wallmounts.
else
{
originPos = _transform.GetMapCoordinates(origin, origin);
}
// Do a raycast to check if relevant
if (inRange)
{
var rayPredicate = GetPredicate(originPos, other, targetPos, targetRot, collisionMask, combinedPredicate);
inRange = InRangeUnobstructed(originPos, targetPos, range, collisionMask, rayPredicate, ShouldCheckAccess(origin));
}
if (!inRange && popup && _gameTiming.IsFirstTimePredicted)
{
var message = Loc.GetString("interaction-system-user-interaction-cannot-reach");
_popupSystem.PopupClient(message, origin, origin);
}
return inRange;
}
public bool InRangeUnobstructed(
MapCoordinates origin,
EntityUid target,
float range = InteractionRange,
CollisionGroup collisionMask = InRangeUnobstructedMask,
Ignored? predicate = null)
{
var transform = Transform(target);
var (position, rotation) = _transform.GetWorldPositionRotation(transform);
var mapPos = new MapCoordinates(position, transform.MapID);
var combinedPredicate = GetPredicate(origin, target, mapPos, rotation, collisionMask, predicate);
return InRangeUnobstructed(origin, mapPos, range, collisionMask, combinedPredicate);
}
///
/// Gets the entities to ignore for an unobstructed raycast
///
///
/// if the target entity is a wallmount we ignore all other entities on the tile.
///
private Ignored GetPredicate(
MapCoordinates originCoords,
EntityUid target,
MapCoordinates targetCoords,
Angle targetRotation,
CollisionGroup collisionMask,
Ignored? predicate = null)
{
HashSet ignored = new();
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.
// TODO: Bandaid, alloc spam
// We use 0.01 range just in case it's perfectly in between 2 walls and 1 gets missed.
foreach (var otherEnt in _lookup.GetEntitiesInRange(target, 0.01f, flags: LookupFlags.Static))
{
if (target == otherEnt ||
!_physicsQuery.TryComp(otherEnt, out var otherBody) ||
!otherBody.CanCollide ||
((int)collisionMask & otherBody.CollisionLayer) == 0x0)
{
continue;
}
ignored.Add(otherEnt);
}
}
else if (_wallMountQuery.TryComp(target, out var wallMount))
{
// wall-mount exemptions may be restricted to a specific angle range.da
bool ignoreAnchored;
if (wallMount.Arc >= Math.Tau)
ignoreAnchored = true;
else
{
var angle = Angle.FromWorldVec(originCoords.Position - targetCoords.Position);
var angleDelta = (wallMount.Direction + targetRotation - angle).Reduced().FlipPositive();
ignoreAnchored = angleDelta < wallMount.Arc / 2 || Math.Tau - angleDelta < wallMount.Arc / 2;
}
if (ignoreAnchored && _mapManager.TryFindGridAt(targetCoords, out var gridUid, out var grid))
ignored.UnionWith(_map.GetAnchoredEntities((gridUid, grid), targetCoords));
}
Ignored combinedPredicate = e => e == target || (predicate?.Invoke(e) ?? false) || ignored.Contains(e);
return combinedPredicate;
}
///
/// Checks that an entity and a set of grid coordinates are within a certain
/// distance without any entity that matches the collision mask
/// obstructing them.
/// If the is zero or negative,
/// this method will only check if nothing obstructs the entity and component.
///
/// The entity to use.
/// The grid coordinates to use.
///
/// Maximum distance between the two entity and set of grid coordinates.
///
/// The mask to check for collisions.
///
/// A predicate to check whether to ignore an entity or not.
/// If it returns true, it will be ignored.
///
///
/// Whether or not to popup a feedback message on the origin entity for
/// it to see.
///
///
/// True if the two points are within a given range without being obstructed.
///
public bool InRangeUnobstructed(
EntityUid origin,
EntityCoordinates other,
float range = InteractionRange,
CollisionGroup collisionMask = InRangeUnobstructedMask,
Ignored? predicate = null,
bool popup = false)
{
return InRangeUnobstructed(origin, _transform.ToMapCoordinates(other), range, collisionMask, predicate, popup);
}
///
/// Checks that an entity and a set of map coordinates are within a certain
/// distance without any entity that matches the collision mask
/// obstructing them.
/// If the is zero or negative,
/// this method will only check if nothing obstructs the entity and component.
///
/// The entity to use.
/// The map coordinates to use.
///
/// Maximum distance between the two entity and set of map coordinates.
///
/// The mask to check for collisions.
///
/// A predicate to check whether to ignore an entity or not.
/// If it returns true, it will be ignored.
///
///
/// Whether or not to popup a feedback message on the origin entity for
/// it to see.
///
///
/// True if the two points are within a given range without being obstructed.
///
public bool InRangeUnobstructed(
EntityUid origin,
MapCoordinates other,
float range = InteractionRange,
CollisionGroup collisionMask = InRangeUnobstructedMask,
Ignored? predicate = null,
bool popup = false)
{
Ignored combinedPredicate = e => e == origin || (predicate?.Invoke(e) ?? false);
var originPosition = _transform.GetMapCoordinates(origin);
var inRange = InRangeUnobstructed(originPosition, other, range, collisionMask, combinedPredicate, ShouldCheckAccess(origin));
if (!inRange && popup && _gameTiming.IsFirstTimePredicted)
{
var message = Loc.GetString("interaction-system-user-interaction-cannot-reach");
_popupSystem.PopupEntity(message, origin, origin);
}
return inRange;
}
public bool RangedInteractDoBefore(
EntityUid user,
EntityUid used,
EntityUid? target,
EntityCoordinates clickLocation,
bool canReach,
bool checkDeletion = true)
{
if (checkDeletion && (IsDeleted(user) || IsDeleted(used) || IsDeleted(target)))
return false;
var ev = new BeforeRangedInteractEvent(user, used, target, clickLocation, canReach);
RaiseLocalEvent(used, ev);
if (!ev.Handled)
return false;
// We contact the USED entity, but not the target.
DoContactInteraction(user, used, ev);
return ev.Handled;
}
///
/// Uses an item/object on an entity
/// Finds components with the InteractUsing interface and calls their function
/// NOTE: Does not have an InRangeUnobstructed check
///
/// User doing the interaction.
/// Item being used on the .
/// Entity getting interacted with by the using the
/// entity.
/// The location that the clicked.
/// Whether to check that the can interact with the
/// .
/// Whether to check that the can use the
/// entity.
/// True if the interaction was handled. Otherwise, false.
public bool InteractUsing(
EntityUid user,
EntityUid used,
EntityUid target,
EntityCoordinates clickLocation,
bool checkCanInteract = true,
bool checkCanUse = true)
{
if (IsDeleted(user) || IsDeleted(used) || IsDeleted(target))
return false;
if (checkCanInteract && !_actionBlockerSystem.CanInteract(user, target))
return false;
if (checkCanUse && !_actionBlockerSystem.CanUseHeldEntity(user, used))
return false;
_adminLogger.Add(
LogType.InteractUsing,
LogImpact.Low,
$"{ToPrettyString(user):user} interacted with {ToPrettyString(target):target} using {ToPrettyString(used):used}");
if (RangedInteractDoBefore(user, used, target, clickLocation, canReach: true, checkDeletion: false))
return true;
DebugTools.Assert(!IsDeleted(user) && !IsDeleted(used) && !IsDeleted(target));
// all interactions should only happen when in range / unobstructed, so no range check is needed
var interactUsingEvent = new InteractUsingEvent(user, used, target, clickLocation);
RaiseLocalEvent(target, interactUsingEvent, true);
DoContactInteraction(user, used, interactUsingEvent);
DoContactInteraction(user, target, interactUsingEvent);
// Contact interactions are currently only used for forensics, so we don't raise used -> target
if (interactUsingEvent.Handled)
return true;
if (InteractDoAfter(user, used, target, clickLocation, canReach: true, checkDeletion: false))
return true;
DebugTools.Assert(!IsDeleted(user) && !IsDeleted(used) && !IsDeleted(target));
return false;
}
///
/// Used when clicking on an entity resulted in no other interaction. Used for low-priority interactions.
///
///
///
///
///
/// Whether the is in range of the .
///
/// True if the interaction was handled. Otherwise, false.
public bool InteractDoAfter(EntityUid user, EntityUid used, EntityUid? target, EntityCoordinates clickLocation, bool canReach, bool checkDeletion = true)
{
if (target is { Valid: false })
target = null;
if (checkDeletion && (IsDeleted(user) || IsDeleted(used) || IsDeleted(target)))
return false;
var afterInteractEvent = new AfterInteractEvent(user, used, target, clickLocation, canReach);
RaiseLocalEvent(used, afterInteractEvent);
DoContactInteraction(user, used, afterInteractEvent);
if (canReach)
{
DoContactInteraction(user, target, afterInteractEvent);
// Contact interactions are currently only used for forensics, so we don't raise used -> target
}
if (afterInteractEvent.Handled)
return true;
if (target == null)
return false;
DebugTools.Assert(!IsDeleted(user) && !IsDeleted(used) && !IsDeleted(target));
var afterInteractUsingEvent = new AfterInteractUsingEvent(user, used, target, clickLocation, canReach);
RaiseLocalEvent(target.Value, afterInteractUsingEvent);
DoContactInteraction(user, used, afterInteractUsingEvent);
if (canReach)
{
DoContactInteraction(user, target, afterInteractUsingEvent);
// Contact interactions are currently only used for forensics, so we don't raise used -> target
}
return afterInteractUsingEvent.Handled;
}
#region ActivateItemInWorld
private bool HandleActivateItemInWorld(ICommonSession? session, EntityCoordinates coords, EntityUid uid)
{
if (!ValidateClientInput(session, coords, uid, out var user))
{
Log.Info($"ActivateItemInWorld input validation failed");
return false;
}
if (Deleted(uid))
return false;
InteractionActivate(user.Value, uid, checkAccess: ShouldCheckAccess(user.Value));
return false;
}
///
/// Raises events and activates the IActivate behavior of an object.
///
///
/// Does not check the can-use action blocker. In activations interacts can target entities outside of the users
/// hands.
///
public bool InteractionActivate(
EntityUid user,
EntityUid used,
bool checkCanInteract = true,
bool checkUseDelay = true,
bool checkAccess = true,
bool? complexInteractions = null,
bool checkDeletion = true)
{
if (checkDeletion && (IsDeleted(user) || IsDeleted(used)))
return false;
DebugTools.Assert(!IsDeleted(user) && !IsDeleted(used));
_delayQuery.TryComp(used, out var delayComponent);
if (checkUseDelay && delayComponent != null && _useDelay.IsDelayed((used, delayComponent)))
return false;
if (checkCanInteract && !_actionBlockerSystem.CanInteract(user, used))
return false;
if (checkAccess && !InRangeUnobstructed(user, used))
return false;
// 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 && !IsAccessible(user, used))
return false;
complexInteractions ??= _actionBlockerSystem.CanComplexInteract(user);
var activateMsg = new ActivateInWorldEvent(user, used, complexInteractions.Value);
RaiseLocalEvent(used, activateMsg, true);
if (activateMsg.Handled)
{
DoContactInteraction(user, used);
if (!activateMsg.WasLogged)
_adminLogger.Add(LogType.InteractActivate, LogImpact.Low, $"{ToPrettyString(user):user} activated {ToPrettyString(used):used}");
if (delayComponent != null)
_useDelay.TryResetDelay(used, component: delayComponent);
return true;
}
DebugTools.Assert(!IsDeleted(user) && !IsDeleted(used));
var userEv = new UserActivateInWorldEvent(user, used, complexInteractions.Value);
RaiseLocalEvent(user, userEv, true);
if (!userEv.Handled)
return false;
DoContactInteraction(user, used);
// Still need to call this even without checkUseDelay in case this gets relayed from Activate.
if (delayComponent != null)
_useDelay.TryResetDelay(used, component: delayComponent);
_adminLogger.Add(LogType.InteractActivate, LogImpact.Low, $"{ToPrettyString(user):user} activated {ToPrettyString(used):used}");
return true;
}
#endregion
#region Hands
#region Use
///
/// Raises UseInHandEvents and activates the IUse behaviors of an entity
/// Does not check accessibility or range, for obvious reasons
///
/// True if the interaction was handled. False otherwise
public bool UseInHandInteraction(
EntityUid user,
EntityUid used,
bool checkCanUse = true,
bool checkCanInteract = true,
bool checkUseDelay = true)
{
if (IsDeleted(user) || IsDeleted(used))
return false;
_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))
return false;
if (checkCanUse && !_actionBlockerSystem.CanUseHeldEntity(user, used))
return false;
var useMsg = new UseInHandEvent(user);
RaiseLocalEvent(used, useMsg, true);
if (useMsg.Handled)
{
DoContactInteraction(user, used, useMsg);
if (delayComponent != null && useMsg.ApplyDelay)
_useDelay.TryResetDelay((used, delayComponent));
return true;
}
DebugTools.Assert(!IsDeleted(user) && !IsDeleted(used));
// else, default to activating the item
return InteractionActivate(user, used, false, false, false, checkDeletion: false);
}
///
/// Alternative interactions on an entity.
///
///
/// Uses the context menu verb list, and acts out the highest priority alternative interaction verb.
///
/// True if the interaction was handled, false otherwise.
public bool AltInteract(EntityUid user, EntityUid target)
{
// Get list of alt-interact verbs
var verbs = _verbSystem.GetLocalVerbs(target, user, typeof(AlternativeVerb));
if (verbs.Count == 0)
return false;
_verbSystem.ExecuteVerb(verbs.First(), user, target);
return true;
}
#endregion
public void DroppedInteraction(EntityUid user, EntityUid item)
{
if (IsDeleted(user) || IsDeleted(item))
return;
var dropMsg = new DroppedEvent(user);
RaiseLocalEvent(item, dropMsg, true);
// If the dropper is rotated then use their targetrelativerotation as the drop rotation
var rotation = Angle.Zero;
if (TryComp(user, out var mover))
{
rotation = mover.TargetRelativeRotation;
}
Transform(item).LocalRotation = rotation;
}
#endregion
///
/// Check if a user can access a target (stored in the same containers) and is in range without obstructions.
///
public bool InRangeAndAccessible(
Entity user,
Entity 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);
}
///
/// Check if a user can access a target or if they are stored in different containers.
///
public bool IsAccessible(Entity user, Entity target)
{
var ev = new AccessibleOverrideEvent(user, target);
RaiseLocalEvent(user, ref ev);
if (ev.Handled)
return ev.Accessible;
if (_containerSystem.IsInSameOrParentContainer(user, target, out _, out var container))
return true;
return container != null && CanAccessViaStorage(user, target, container);
}
///
/// 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.
///
public bool CanAccessViaStorage(EntityUid user, EntityUid target)
{
if (!_containerSystem.TryGetContainingContainer((target, null, null), out var container))
return false;
return CanAccessViaStorage(user, target, container);
}
///
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(container.Owner, StorageComponent.StorageUiKey.Key, user);
}
///
/// Checks whether an entity currently equipped by another player is accessible to some user. This shouldn't
/// be used as a general interaction check, as these kinda of interactions should generally trigger a
/// do-after and a warning for the other player.
///
public bool CanAccessEquipment(EntityUid user, EntityUid target)
{
if (Deleted(target))
return false;
if (!_containerSystem.TryGetContainingContainer(target, out var container))
return false;
var wearer = container.Owner;
if (!_inventory.TryGetSlot(wearer, container.ID, out var slotDef))
return false;
if (wearer == user)
return true;
if (_strippable.IsStripHidden(slotDef, user))
return false;
return InRangeUnobstructed(user, wearer) && _containerSystem.IsInSameOrParentContainer(user, wearer);
}
protected bool ValidateClientInput(
ICommonSession? session,
EntityCoordinates coords,
EntityUid uid,
[NotNullWhen(true)] out EntityUid? userEntity)
{
userEntity = null;
if (!coords.IsValid(EntityManager))
{
Log.Info($"Invalid Coordinates: client={session}, coords={coords}");
return false;
}
if (IsClientSide(uid))
{
Log.Warning($"Client sent interaction with client-side entity. Session={session}, Uid={uid}");
return false;
}
userEntity = session?.AttachedEntity;
if (userEntity == null || !userEntity.Value.Valid)
{
Log.Warning($"Client sent interaction with no attached entity. Session={session}");
return false;
}
if (!Exists(userEntity))
{
Log.Warning($"Client attempted interaction with a non-existent attached entity. Session={session}, entity={userEntity}");
return false;
}
return _rateLimit.CountAction(session!, RateLimitKey) == RateLimitStatus.Allowed;
}
///
/// Simple convenience function to raise contact events (disease, forensics, etc).
///
public void DoContactInteraction(EntityUid uidA, EntityUid? uidB, HandledEntityEventArgs? args = null)
{
if (uidB == null || args?.Handled == false)
return;
if (uidA == uidB.Value)
return;
if (!TryComp(uidA, out MetaDataComponent? metaA) || metaA.EntityPaused)
return;
if (!TryComp(uidB, out MetaDataComponent? metaB) || metaB.EntityPaused)
return ;
// TODO Struct event
var ev = new ContactInteractionEvent(uidB.Value);
RaiseLocalEvent(uidA, ev);
ev.Other = uidA;
RaiseLocalEvent(uidB.Value, ev);
}
private void HandleUserInterfaceRangeCheck(ref BoundUserInterfaceCheckRangeEvent ev)
{
if (ev.Result == BoundUserInterfaceRangeResult.Fail)
return;
ev.Result = UiRangeCheck(ev.Actor!, ev.Target, ev.Data.InteractionRange)
? BoundUserInterfaceRangeResult.Pass
: BoundUserInterfaceRangeResult.Fail;
}
///
/// Gets the entity that is currently being "used" for the interaction.
/// In most cases, this refers to the entity in the character's active hand.
///
/// If there is an entity being used.
public bool TryGetUsedEntity(EntityUid user, [NotNullWhen(true)] out EntityUid? used, bool checkCanUse = true)
{
var ev = new GetUsedEntityEvent(user);
RaiseLocalEvent(user, ref ev);
used = ev.Used;
if (!ev.Handled)
return false;
// Can the user use the held entity?
if (checkCanUse && !_actionBlockerSystem.CanUseHeldEntity(user, ev.Used!.Value))
{
used = null;
return false;
}
return ev.Handled;
}
[Obsolete("Use ActionBlockerSystem")]
public bool SupportsComplexInteractions(EntityUid user)
{
return _actionBlockerSystem.CanComplexInteract(user);
}
}
///
/// Raised when a player attempts to activate an item in an inventory slot or hand slot
///
[Serializable, NetSerializable]
public sealed class InteractInventorySlotEvent : EntityEventArgs
{
///
/// Entity that was interacted with.
///
public NetEntity ItemUid { get; }
///
/// Whether the interaction used the alt-modifier to trigger alternative interactions.
///
public bool AltInteract { get; }
public InteractInventorySlotEvent(NetEntity itemUid, bool altInteract = false)
{
ItemUid = itemUid;
AltInteract = altInteract;
}
}
///
/// Raised directed by-ref on an entity to determine what item will be used in interactions.
///
[ByRefEvent]
public record struct GetUsedEntityEvent(EntityUid User)
{
public EntityUid User = User;
public EntityUid? Used = null;
public bool Handled => Used != null;
};
///
/// Raised directed by-ref on an item to determine if hand interactions should go through.
/// Defaults to allowing hand interactions to go through. Cancel to force the item to be attacked instead.
///
/// Whether the hand interaction should be cancelled.
[ByRefEvent]
public record struct CombatModeShouldHandInteractEvent(bool Cancelled = false);
///
/// Override event raised directed on the user to say the target is accessible.
///
///
///
[ByRefEvent]
public record struct AccessibleOverrideEvent(EntityUid User, EntityUid Target)
{
public readonly EntityUid User = User;
public readonly EntityUid Target = Target;
public bool Handled;
public bool Accessible = false;
}
///
/// Override event raised directed on a user to check InRangeUnoccluded AND InRangeUnobstructed to the target if you require custom logic.
///
[ByRefEvent]
public record struct InRangeOverrideEvent(EntityUid User, EntityUid Target)
{
public readonly EntityUid User = User;
public readonly EntityUid Target = Target;
public bool Handled;
public bool InRange = false;
}
}