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.Hands;
using Content.Shared.Hands.Components;
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.Physics;
using Content.Shared.Popups;
using Content.Shared.Pulling;
using Content.Shared.Pulling.Components;
using Content.Shared.Tag;
using Content.Shared.Timing;
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.Components;
using Robust.Shared.Physics.Systems;
using Robust.Shared.Player;
using Robust.Shared.Random;
using Robust.Shared.Serialization;
using Robust.Shared.Timing;
#pragma warning disable 618
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 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 SharedTransformSystem _transform = default!;
[Dependency] private readonly SharedVerbSystem _verbSystem = default!;
[Dependency] private readonly SharedPopupSystem _popupSystem = default!;
[Dependency] private readonly UseDelaySystem _useDelay = default!;
[Dependency] private readonly SharedPullingSystem _pullSystem = default!;
[Dependency] private readonly InventorySystem _inventory = default!;
[Dependency] private readonly IRobustRandom _random = default!;
[Dependency] private readonly TagSystem _tagSystem = default!;
private 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 delegate bool Ignored(EntityUid entity);
public override void Initialize()
{
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();
InitializeBlocking();
}
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(BoundUserInterfaceMessageAttempt ev)
{
if (ev.Sender.AttachedEntity is not { } user || !_actionBlockerSystem.CanInteract(user, 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))
{
ev.Cancel();
return;
}
if (!InRangeUnobstructed(user, ev.Target))
{
ev.Cancel();
}
}
///
/// 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)
{
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 if (_net.IsServer)
QueueDel(uid);
}
private void OnUnequipHand(EntityUid uid, UnremoveableComponent item, GotUnequippedHandEvent args)
{
if (!item.DeleteOnDrop)
RemCompDeferred(uid);
else if (_net.IsServer)
QueueDel(uid);
}
private void OnDropped(EntityUid uid, UnremoveableComponent item, DroppedEvent args)
{
if (!item.DeleteOnDrop)
RemCompDeferred(uid);
else if (_net.IsServer)
QueueDel(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;
if (!TryComp(uid, out SharedPullableComponent? pull))
return false;
_pullSystem.TogglePull(userEntity.Value, pull);
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, "BypassInteractionRangeChecks");
}
///
/// 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 || !TryComp(user, out var hands) || hands.ActiveHand?.HeldEntity 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 (!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 (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 && 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
&& !_containerSystem.IsInSameOrParentContainer(user, target.Value)
&& !CanAccessViaStorage(user, target.Value))
return;
var inRangeUnobstructed = target == null
? !checkAccess || InRangeUnobstructed(user, coordinates)
: !checkAccess || InRangeUnobstructed(user, target.Value); // permits interactions with wall mounted entities
// Does the user have hands?
if (!TryComp(user, out var hands) || hands.ActiveHand == null)
{
var ev = new InteractNoHandEvent(user, target, coordinates);
RaiseLocalEvent(user, ev);
if (target != null)
{
var interactedEv = new InteractedNoHandEvent(target.Value, user, coordinates);
RaiseLocalEvent(target.Value, interactedEv);
DoContactInteraction(user, target.Value, ev);
}
return;
}
// empty-hand interactions
// combat mode hand interactions will always be true here -- since
// they check this earlier before returning in
if (hands.ActiveHandEntity is not { } held)
{
if (inRangeUnobstructed && target != null)
InteractHand(user, target.Value);
return;
}
// Can the user use the held entity?
if (checkCanUse && !_actionBlockerSystem.CanUseHeldEntity(user))
return;
if (target == held)
{
UseInHandInteraction(user, target.Value, checkCanUse: false, checkCanInteract: false);
return;
}
if (inRangeUnobstructed && target != null)
{
InteractUsing(
user,
held,
target.Value,
coordinates,
checkCanInteract: false,
checkCanUse: false);
return;
}
InteractUsingRanged(
user,
held,
target,
coordinates,
inRangeUnobstructed);
}
public void InteractHand(EntityUid user, EntityUid target)
{
// 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;
}
// 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;
// Else we run Activate.
InteractionActivate(user, target,
checkCanInteract: false,
checkUseDelay: true,
checkAccess: false);
}
public void InteractUsingRanged(EntityUid user, EntityUid used, EntityUid? target,
EntityCoordinates clickLocation, bool inRangeUnobstructed)
{
if (RangedInteractDoBefore(user, used, target, clickLocation, inRangeUnobstructed))
return;
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;
}
InteractDoAfter(user, used, target, clickLocation, inRangeUnobstructed);
}
protected bool ValidateInteractAndFace(EntityUid user, EntityCoordinates coordinates)
{
// Verify user is on the same map as the entity they clicked on
if (coordinates.GetMapId(EntityManager) != Transform(user).MapID)
return false;
if (!HasComp(user))
_rotateToFaceSystem.TryFaceCoordinates(user, coordinates.ToMapPos(EntityManager));
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 = _sharedBroadphaseSystem.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 = _sharedBroadphaseSystem.IntersectRayWithPredicate(origin.MapId, ray, length, predicate.Invoke, false).ToList();
return rayResults.Count == 0;
}
public bool InRangeUnobstructed(
EntityUid origin,
EntityUid other,
float range = InteractionRange,
CollisionGroup collisionMask = InRangeUnobstructedMask,
Ignored? predicate = null,
bool popup = false)
{
if (!TryComp(other, out var otherXform))
return false;
return InRangeUnobstructed(origin, other, otherXform.Coordinates, otherXform.LocalRotation, range, collisionMask, predicate,
popup);
}
///
/// 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.
///
public bool InRangeUnobstructed(
EntityUid origin,
EntityUid other,
EntityCoordinates otherCoordinates,
Angle otherAngle,
float range = InteractionRange,
CollisionGroup collisionMask = InRangeUnobstructedMask,
Ignored? predicate = null,
bool popup = false)
{
Ignored combinedPredicate = e => e == origin || (predicate?.Invoke(e) ?? false);
var inRange = true;
MapCoordinates originPos = default;
var targetPos = otherCoordinates.ToMap(EntityManager);
Angle targetRot = default;
// 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 &&
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 &&
TryComp(other, out var fixtureB) &&
fixtureB.FixtureCount > 0 &&
TryComp(origin, out var xformA))
{
var (worldPosA, worldRotA) = xformA.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))
{
inRange = false;
}
// Overlap, early out and no raycast.
else if (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 = xformA.MapPosition;
range = (originPos.Position - targetPos.Position).Length();
}
}
// No fixtures, e.g. wallmounts.
else
{
originPos = Transform(origin).MapPosition;
var otherParent = Transform(other).ParentUid;
targetRot = otherParent.IsValid() ? Transform(otherParent).LocalRotation + otherAngle : otherAngle;
}
// 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.PopupEntity(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();
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 origin,
EntityUid target,
MapCoordinates targetCoords,
Angle targetRotation,
CollisionGroup collisionMask,
Ignored? predicate = null)
{
HashSet ignored = new();
if (HasComp(target) && TryComp(target, out PhysicsComponent? 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));
}
else if (TryComp(target, out WallMountComponent? 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(origin.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 _, out var grid))
ignored.UnionWith(grid.GetAnchoredEntities(targetCoords));
}
Ignored combinedPredicate = e =>
{
return 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, other.ToMap(EntityManager), 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(origin).MapPosition;
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)
{
var ev = new BeforeRangedInteractEvent(user, used, target, clickLocation, canReach);
RaiseLocalEvent(used, ev);
// We contact the USED entity, but not the target.
DoContactInteraction(user, used, ev);
return ev.Handled;
}
///
/// Uses a item/object on an entity
/// Finds components with the InteractUsing interface and calls their function
/// NOTE: Does not have an InRangeUnobstructed check
///
public void InteractUsing(
EntityUid user,
EntityUid used,
EntityUid target,
EntityCoordinates clickLocation,
bool checkCanInteract = true,
bool checkCanUse = true)
{
if (checkCanInteract && !_actionBlockerSystem.CanInteract(user, target))
return;
if (checkCanUse && !_actionBlockerSystem.CanUseHeldEntity(user))
return;
if (RangedInteractDoBefore(user, used, target, clickLocation, true))
return;
// 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);
DoContactInteraction(used, target, interactUsingEvent);
if (interactUsingEvent.Handled)
return;
InteractDoAfter(user, used, target, clickLocation, canReach: true);
}
///
/// Used when clicking on an entity resulted in no other interaction. Used for low-priority interactions.
///
public void InteractDoAfter(EntityUid user, EntityUid used, EntityUid? target, EntityCoordinates clickLocation, bool canReach)
{
if (target is {Valid: false})
target = null;
var afterInteractEvent = new AfterInteractEvent(user, used, target, clickLocation, canReach);
RaiseLocalEvent(used, afterInteractEvent);
DoContactInteraction(user, used, afterInteractEvent);
if (canReach)
{
DoContactInteraction(user, target, afterInteractEvent);
DoContactInteraction(used, target, afterInteractEvent);
}
if (afterInteractEvent.Handled)
return;
if (target == null)
return;
var afterInteractUsingEvent = new AfterInteractUsingEvent(user, used, target, clickLocation, canReach);
RaiseLocalEvent(target.Value, afterInteractUsingEvent);
DoContactInteraction(user, used, afterInteractUsingEvent);
if (canReach)
{
DoContactInteraction(user, target, afterInteractUsingEvent);
DoContactInteraction(used, target, afterInteractUsingEvent);
}
}
#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)
{
UseDelayComponent? delayComponent = null;
if (checkUseDelay
&& TryComp(used, out delayComponent)
&& _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 && !_containerSystem.IsInSameOrParentContainer(user, used) && !CanAccessViaStorage(user, used))
return false;
// Does the user have hands?
if (!HasComp(user))
return false;
var activateMsg = new ActivateInWorldEvent(user, used);
RaiseLocalEvent(used, activateMsg, true);
if (!activateMsg.Handled)
return false;
DoContactInteraction(user, used, activateMsg);
if (delayComponent != null)
_useDelay.TryResetDelay((used, delayComponent));
if (!activateMsg.WasLogged)
_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)
{
UseDelayComponent? delayComponent = null;
if (checkUseDelay
&& TryComp(used, out delayComponent)
&& _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))
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;
}
// else, default to activating the item
return InteractionActivate(user, used, false, false, 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.Any())
return false;
_verbSystem.ExecuteVerb(verbs.First(), user, target);
return true;
}
#endregion
public void DroppedInteraction(EntityUid user, EntityUid item)
{
var dropMsg = new DroppedEvent(user);
RaiseLocalEvent(item, dropMsg, true);
if (dropMsg.Handled)
_adminLogger.Add(LogType.Drop, LogImpact.Low, $"{ToPrettyString(user):user} dropped {ToPrettyString(item):entity}");
// 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
///
/// 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 abstract bool CanAccessViaStorage(EntityUid user, EntityUid target);
///
/// 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 (slotDef.StripHidden)
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 true;
}
///
/// 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;
// Entities may no longer exist (banana was eaten, or human was exploded)?
if (!Exists(uidA) || !Exists(uidB))
return;
if (Paused(uidA) || Paused(uidB.Value))
return;
RaiseLocalEvent(uidA, new ContactInteractionEvent(uidB.Value));
RaiseLocalEvent(uidB.Value, new ContactInteractionEvent(uidA));
}
}
///
/// 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 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);
}