Drag and Drop Godot 4 Controls


Sandcrawler is a co-op game in which players take on various roles around the inside of an enormous tank to help the team reach their destination safely. One of these roles is the “engineer”, who’s often on the reactor console fine tuning various tank configuration aspects.

One of the major components of the reactor console is the power allocation widget, which is kind of a souped-up version of FTL’s power system:

FTL’s power system

And here’s the analogous component in Sandcrawler:

Sandcrawler’s power allocation system

This is interesting is because most tutorials on how to do drag-and-drop in Godot seem to rely on physics objects like Area2D and using those to detect collisions and overlap between the dragged object and the drop target. I didn’t want to do this for a couple reasons: firstly, these components are very much CanvasItem (UI) components; I didn’t want to pull in the physics engine when CanvasItems already have perfectly serviceable mouseenter/mouseexit events. The second reason is the UI is very dense – the columns are close together, and the rows within the columns are tight too. The behavior I wanted was not based on the dragged object’s location (and bounds), but rather based on the cursor’s location. This is a critical distinction and should be more precise.

There is one problem, though: if you’re dragging an object (a Button in my case), then it catches all the events, including mouseenter. How do you know when you’re over the thing under the button?

The trick is to leverage MouseFilter, a property on controls that indicates how, or if, to receive mouse events.

Let’s quickly review the three values and their implications:

MOUSE_FILTER_STOP: this is the default value and means the mouse event doesn’t propagate beyond the target because it’s already been handled. We don’t want this since we want the drop target to also pick up the event.

MOUSE_FILTER_PASS: this one’s the most complicated. The target gets the mouse event, but the parent of the target also gets the mouse event. However, the parent will also pass the event to later siblings of the target if they’re under mouse too.

MOUSE_FILTER_IGNORE: the target will completely miss and ignore all events.

MOUSE_FILTER_PASS almost works in this case, but has an interesting behavior: you can only drag and drop to a node lower in the scene tree. Because it seems to only pass events to later siblings and not all siblings, it gives this kind of weird one-directional behavior. Additionally, this might not even be what we want, if our drop target is in a completely different part of the scene tree.

Already, I’ve buried the lede enough: the solution is to use MOUSE_FILTER_STOP initially and then after the drag has started, switch the dragged object to MOUSE_FILTER_IGNORE. This way all mouse events completely ignore the target, allowing the drop target to receive events properly.

This leads to some of the following weird code (in C#):

public partial class PowerUnit : Button {
    private Vector2? initialButtonPosition;
    private Vector2 initialMousePosition;

    public override void _Ready() {
        ButtonDown += OnButtonDown;
        MouseFilter = MouseFilterEnum.Stop;
    }

    private void OnButtonDown() {
        // When we start dragging, we need to remember where various positions are
        initialButtonPosition = GlobalPosition;
        initialMousePosition = GetGlobalMousePosition();
        // Here we disable mouse events so that the drop target can receive them instead
        MouseFilter = MouseFilterEnum.Ignore;
    }

    private void OnButtonUp() {
        // Don't forget to turn mouse events back on!
        MouseFilter = MouseFilterEnum.Stop;
        // TODO move to the center of the drop target
    }

    public void Reset() {
        // This is how we differentiate whether we're currently dragging
        initialButtonPosition = null;
    }

    public void ResetToOriginalPosition() {
        // You don't have to tween obviously, but it looks nice.
        var tween = CreateTween();
        tween.TweenProperty(this, "global_position", initialButtonPosition.Value, 0.5f);
        Reset();
    }

    public override void _Process(double delta) {
        if (initialButtonPosition != null) {
            // Poll to determine whether the mouse button was just released or
            // if we should update the position of the dragged control
            if (!Input.IsMouseButtonPressed(MouseButton.Left)) {
                OnButtonUp();
            } else {
                var newMousePosition = GetGlobalMousePosition();
                var deltaPosition = newMousePosition - initialMousePosition;
                GlobalPosition = initialButtonPosition.Value + deltaPosition;
            }
        }
    }
}

The weird part is while we can subscribe to the ButtonDown event as usual, we can’t subscribe to the ButtonUp event. Why? Because that event will never fire! The ButtonDown event handler has turned off all mouse events, so instead we have to resort to polling as you can see in _Process. (Also the TODO isn’t there in my actual code; I emit an “I’m done!” event that another component receives to position the dragged object correctly.)


But yeah, that’s the trick. Drag-and-drop controls in Godot can be implemented easily by switching the MouseFilter property when the drag operation starts to IGNORE and back to STOP the rest of the time. Sandcrawler leverages UI like this (and sliders, lots of sliders) to empower the player to optimize their tank.

Godot 4.3 Beta 2

Leave a comment

Log in with itch.io to leave a comment.