using System.Numerics; using Content.Client.CombatMode; using Content.Client.Gameplay; using Content.Client.Outline; using Content.Shared.ActionBlocker; using Content.Shared.CCVar; using Content.Shared.DragDrop; using Content.Shared.Interaction; using Content.Shared.Interaction.Events; using Content.Shared.Popups; using Robust.Client.GameObjects; using Robust.Client.Graphics; using Robust.Client.Input; using Robust.Client.Player; using Robust.Client.State; using Robust.Shared.Configuration; using Robust.Shared.Input; using Robust.Shared.Input.Binding; using Robust.Shared.Map; using Robust.Shared.Player; using Robust.Shared.Prototypes; using Robust.Shared.Utility; using DrawDepth = Content.Shared.DrawDepth.DrawDepth; namespace Content.Client.Interaction; /// /// Handles clientside drag and drop logic /// public sealed class DragDropSystem : SharedDragDropSystem { [Dependency] private readonly IStateManager _stateManager = default!; [Dependency] private readonly IInputManager _inputManager = default!; [Dependency] private readonly IEyeManager _eyeManager = default!; [Dependency] private readonly IPlayerManager _playerManager = default!; [Dependency] private readonly IPrototypeManager _prototypeManager = default!; [Dependency] private readonly IConfigurationManager _cfgMan = default!; [Dependency] private readonly InteractionOutlineSystem _outline = default!; [Dependency] private readonly SharedInteractionSystem _interactionSystem = default!; [Dependency] private readonly CombatModeSystem _combatMode = default!; [Dependency] private readonly InputSystem _inputSystem = default!; [Dependency] private readonly ActionBlockerSystem _actionBlockerSystem = default!; [Dependency] private readonly EntityLookupSystem _lookup = default!; [Dependency] private readonly SharedPopupSystem _popup = default!; [Dependency] private readonly SharedTransformSystem _transformSystem = default!; // how often to recheck possible targets (prevents calling expensive // check logic each update) private const float TargetRecheckInterval = 0.25f; // if a drag ends up being cancelled and it has been under this // amount of time since the mousedown, we will "replay" the original // mousedown event so it can be treated like a regular click private const float MaxMouseDownTimeForReplayingClick = 0.85f; [ValidatePrototypeId] private const string ShaderDropTargetInRange = "SelectionOutlineInrange"; [ValidatePrototypeId] private const string ShaderDropTargetOutOfRange = "SelectionOutline"; /// /// Current entity being dragged around. /// private EntityUid? _draggedEntity; /// /// If an entity is being dragged is there a drag shadow. /// private EntityUid? _dragShadow; /// /// Time since mouse down over the dragged entity /// private float _mouseDownTime; /// /// how much time since last recheck of all possible targets /// private float _targetRecheckTime; /// /// Reserved initial mousedown event so we can replay it if no drag ends up being performed /// private PointerInputCmdHandler.PointerInputCmdArgs? _savedMouseDown; /// /// Whether we are currently replaying the original mouse down, so we /// can ignore any events sent to this system /// private bool _isReplaying; public float Deadzone; private DragState _state = DragState.NotDragging; /// /// screen pos where the mouse down began for the drag /// private ScreenCoordinates? _mouseDownScreenPos; private ShaderInstance? _dropTargetInRangeShader; private ShaderInstance? _dropTargetOutOfRangeShader; private readonly List _highlightedSprites = new(); public override void Initialize() { base.Initialize(); UpdatesOutsidePrediction = true; UpdatesAfter.Add(typeof(SharedEyeSystem)); Subs.CVar(_cfgMan, CCVars.DragDropDeadZone, SetDeadZone, true); _dropTargetInRangeShader = _prototypeManager.Index(ShaderDropTargetInRange).Instance(); _dropTargetOutOfRangeShader = _prototypeManager.Index(ShaderDropTargetOutOfRange).Instance(); // needs to fire on mouseup and mousedown so we can detect a drag / drop CommandBinds.Builder .BindBefore(EngineKeyFunctions.Use, new PointerInputCmdHandler(OnUse, false, true), new[] { typeof(SharedInteractionSystem) }) .Register(); } private void SetDeadZone(float deadZone) { Deadzone = deadZone; } public override void Shutdown() { CommandBinds.Unregister(); base.Shutdown(); } private bool OnUse(in PointerInputCmdHandler.PointerInputCmdArgs args) { // not currently predicted if (_inputSystem.Predicted) return false; // currently replaying a saved click, don't handle this because // we already decided this click doesn't represent an actual drag attempt if (_isReplaying) return false; if (args.State == BoundKeyState.Down) { return OnUseMouseDown(args); } if (args.State == BoundKeyState.Up) { return OnUseMouseUp(args); } return false; } private void EndDrag() { if (_state == DragState.NotDragging) return; if (_dragShadow != null) { Del(_dragShadow.Value); _dragShadow = null; } _draggedEntity = null; _state = DragState.NotDragging; _mouseDownScreenPos = null; RemoveHighlights(); _outline.SetEnabled(true); _mouseDownTime = 0; _savedMouseDown = null; } private bool OnUseMouseDown(in PointerInputCmdHandler.PointerInputCmdArgs args) { if (args.Session?.AttachedEntity is not {Valid: true} dragger || _combatMode.IsInCombatMode()) { return false; } // cancel any current dragging if there is one (shouldn't be because they would've had to have lifted // the mouse, canceling the drag, but just being cautious) EndDrag(); var entity = args.EntityUid; // possibly initiating a drag // check if the clicked entity is draggable if (!Exists(entity)) { return false; } // check if the entity is reachable if (!_interactionSystem.InRangeUnobstructed(dragger, entity)) { return false; } var ev = new CanDragEvent(); RaiseLocalEvent(entity, ref ev); if (ev.Handled != true) return false; _draggedEntity = entity; _state = DragState.MouseDown; _mouseDownScreenPos = args.ScreenCoordinates; _mouseDownTime = 0; // don't want anything else to process the click, // but we will save the event so we can "re-play" it if this drag does // not turn into an actual drag so the click can be handled normally _savedMouseDown = args; return true; } private void StartDrag() { if (!Exists(_draggedEntity)) { // something happened to the clicked entity or we moved the mouse off the target so // we shouldn't replay the original click return; } _state = DragState.Dragging; DebugTools.Assert(_dragShadow == null); _outline.SetEnabled(false); HighlightTargets(); if (TryComp(_draggedEntity, out var draggedSprite)) { var screenPos = _inputManager.MouseScreenPosition; // No _draggedEntity in null window (Happens in tests) if (!screenPos.IsValid) return; // pop up drag shadow under mouse var mousePos = _eyeManager.PixelToMap(screenPos); _dragShadow = EntityManager.SpawnEntity("dragshadow", mousePos); var dragSprite = Comp(_dragShadow.Value); dragSprite.CopyFrom(draggedSprite); dragSprite.RenderOrder = EntityManager.CurrentTick.Value; dragSprite.Color = dragSprite.Color.WithAlpha(0.7f); // keep it on top of everything dragSprite.DrawDepth = (int) DrawDepth.Overlays; if (!dragSprite.NoRotation) { _transformSystem.SetWorldRotationNoLerp(_dragShadow.Value, _transformSystem.GetWorldRotation(_draggedEntity.Value)); } // drag initiated return; } Log.Warning($"Unable to display drag shadow for {ToPrettyString(_draggedEntity.Value)} because it has no sprite component."); } private bool UpdateDrag(float frameTime) { if (!Exists(_draggedEntity) || _combatMode.IsInCombatMode()) { EndDrag(); return false; } var player = _playerManager.LocalEntity; // still in range of the thing we are dragging? if (player == null || !_interactionSystem.InRangeUnobstructed(player.Value, _draggedEntity.Value)) { return false; } if (_dragShadow == null) return false; _targetRecheckTime += frameTime; if (_targetRecheckTime > TargetRecheckInterval) { HighlightTargets(); _targetRecheckTime -= TargetRecheckInterval; } return true; } private bool OnUseMouseUp(in PointerInputCmdHandler.PointerInputCmdArgs args) { if (_state == DragState.MouseDown) { // haven't started the drag yet, quick mouseup, definitely treat it as a normal click by // replaying the original cmd try { if (_savedMouseDown.HasValue && _mouseDownTime < MaxMouseDownTimeForReplayingClick) { var savedValue = _savedMouseDown.Value; _isReplaying = true; // adjust the timing info based on the current tick so it appears as if it happened now var replayMsg = savedValue.OriginalMessage; switch (replayMsg) { case ClientFullInputCmdMessage clientInput: replayMsg = new ClientFullInputCmdMessage(args.OriginalMessage.Tick, args.OriginalMessage.SubTick, replayMsg.InputFunctionId) { State = replayMsg.State, Coordinates = clientInput.Coordinates, ScreenCoordinates = clientInput.ScreenCoordinates, Uid = clientInput.Uid, }; break; case FullInputCmdMessage fullInput: replayMsg = new FullInputCmdMessage(args.OriginalMessage.Tick, args.OriginalMessage.SubTick, replayMsg.InputFunctionId, replayMsg.State, fullInput.Coordinates, fullInput.ScreenCoordinates, fullInput.Uid); break; default: throw new ArgumentOutOfRangeException(); } if (savedValue.Session != null) { _inputSystem.HandleInputCommand(savedValue.Session, EngineKeyFunctions.Use, replayMsg, true); } _isReplaying = false; } } finally { EndDrag(); } return false; } var localPlayer = _playerManager.LocalEntity; if (localPlayer == null || !Exists(_draggedEntity)) { EndDrag(); return false; } IEnumerable entities; var coords = args.Coordinates; if (_stateManager.CurrentState is GameplayState screen) { entities = screen.GetClickableEntities(coords); } else { entities = Array.Empty(); } var outOfRange = false; var user = localPlayer.Value; foreach (var entity in entities) { if (entity == _draggedEntity) continue; // check if it's able to be dropped on by current dragged entity var valid = ValidDragDrop(user, _draggedEntity.Value, entity); if (valid != true) continue; if (!_interactionSystem.InRangeUnobstructed(user, entity) || !_interactionSystem.InRangeUnobstructed(user, _draggedEntity.Value)) { outOfRange = true; continue; } // tell the server about the drop attempt RaisePredictiveEvent(new DragDropRequestEvent(GetNetEntity(_draggedEntity.Value), GetNetEntity(entity))); EndDrag(); return true; } if (outOfRange) { _popup.PopupEntity(Loc.GetString("drag-drop-system-out-of-range-text"), _draggedEntity.Value, Filter.Local(), true); } EndDrag(); return false; } // TODO make this just use TargetOutlineSystem private void HighlightTargets() { if (!Exists(_draggedEntity) || !Exists(_dragShadow)) { return; } var user = _playerManager.LocalEntity; if (user == null) return; // highlights the possible targets which are visible // and able to be dropped on by the current dragged entity // remove current highlights RemoveHighlights(); // find possible targets on screen even if not reachable // TODO: Duplicated in SpriteSystem and TargetOutlineSystem. Should probably be cached somewhere for a frame? var mousePos = _eyeManager.PixelToMap(_inputManager.MouseScreenPosition); var expansion = new Vector2(1.5f, 1.5f); var bounds = new Box2(mousePos.Position - expansion, mousePos.Position + expansion); var pvsEntities = _lookup.GetEntitiesIntersecting(mousePos.MapId, bounds); var spriteQuery = GetEntityQuery(); foreach (var entity in pvsEntities) { if (!spriteQuery.TryGetComponent(entity, out var inRangeSprite) || !inRangeSprite.Visible || entity == _draggedEntity) { continue; } var valid = ValidDragDrop(user.Value, _draggedEntity.Value, entity); // check if it's able to be dropped on by current dragged entity if (valid == null) continue; // We'll do a final check given server-side does this before any dragdrop can take place. if (valid.Value) { valid = _interactionSystem.InRangeUnobstructed(user.Value, _draggedEntity.Value) && _interactionSystem.InRangeUnobstructed(user.Value, entity); } if (inRangeSprite.PostShader != null && inRangeSprite.PostShader != _dropTargetInRangeShader && inRangeSprite.PostShader != _dropTargetOutOfRangeShader) { continue; } // highlight depending on whether its in or out of range inRangeSprite.PostShader = valid.Value ? _dropTargetInRangeShader : _dropTargetOutOfRangeShader; inRangeSprite.RenderOrder = EntityManager.CurrentTick.Value; _highlightedSprites.Add(inRangeSprite); } } private void RemoveHighlights() { foreach (var highlightedSprite in _highlightedSprites) { if (highlightedSprite.PostShader != _dropTargetInRangeShader && highlightedSprite.PostShader != _dropTargetOutOfRangeShader) continue; highlightedSprite.PostShader = null; highlightedSprite.RenderOrder = 0; } _highlightedSprites.Clear(); } /// /// Are these args valid for drag-drop? /// /// /// Returns null if no interactions are available or the user / target cannot interact with each other. /// Returns false if interactions exist but are not available currently. /// private bool? ValidDragDrop(EntityUid user, EntityUid dragged, EntityUid target) { if (!_actionBlockerSystem.CanInteract(user, target)) return null; // CanInteract() doesn't support checking a second "target" entity. // Doing so manually: var ev = new GettingInteractedWithAttemptEvent(user, dragged); RaiseLocalEvent(dragged, ref ev); if (ev.Cancelled) return false; var dropEv = new CanDropDraggedEvent(user, target); RaiseLocalEvent(dragged, ref dropEv); if (dropEv.Handled) { if (!dropEv.CanDrop) return false; } var dropEv2 = new CanDropTargetEvent(user, dragged); RaiseLocalEvent(target, ref dropEv2); if (dropEv2.Handled) return dropEv2.CanDrop; if (dropEv.Handled && dropEv.CanDrop) return true; return null; } public override void Update(float frameTime) { base.Update(frameTime); switch (_state) { // check if dragging should begin case DragState.MouseDown: { var screenPos = _inputManager.MouseScreenPosition; if ((_mouseDownScreenPos!.Value.Position - screenPos.Position).Length() > Deadzone) { StartDrag(); } break; } case DragState.Dragging: UpdateDrag(frameTime); break; } } public override void FrameUpdate(float frameTime) { base.FrameUpdate(frameTime); // Update position every frame to make it smooth. if (Exists(_dragShadow)) { var mousePos = _eyeManager.PixelToMap(_inputManager.MouseScreenPosition); _transformSystem.SetWorldPosition(_dragShadow.Value, mousePos.Position); } } } public enum DragState : byte { NotDragging, /// /// Not dragging yet, waiting to see /// if they hold for long enough /// MouseDown, /// /// Currently dragging something /// Dragging, }