Search code examples
signalsgodot

Godot 4.0.b7: Attach mouse-entered signal via code to dynamically generated CollisionShape2D


I have an array of dynamically generated 'planets' that need to be able to interact with the mouse. I have been trying to use the new Godot 4 signals format, but no joy yet.

The following code works correctly:

func _ready():
    for planet in planets:
        var planet_collision_shape = CollisionShape2D.new()
        planet_collision_shape.shape = CircleShape2D.new()
        planet_collision_shape.shape.radius = 27.5
        planet_collision_shape.position = Vector2(planet._coord[0], planet._coord[1])
        add_child(planet_collision_shape)
        planet._body = planet_collision_shape

But attempts to implement a signal with the following code generates an error code of:

Invalid get index 'mouse_entered' (on base: 'CircleShape2D').

        planet._body.shape.mouse_entered.connect(self._on_mouse_entered)

The node tree is nested as follows, with the dynamically generated nodes attaching to the Camera2D.

Node2D -> CanvasLayer -> Camera2D

********** EDIT **********

@ Theraot: Well, I genuinely thought I was close to a solution before your comment, but it seems that my code has confused even you. Here is the full script:

extends Node2D


var mouse_entered

@onready var camera = $"."

var planets = Global.planets
var zoom_minimum = Vector2(.5, .5)
var zoom_maximum = Vector2(2.5, 2.5)
var zoom_speed = Vector2(.1, .1)
var velocity = Vector2.ZERO


func _ready():
    for planet in planets:
        var planet_collision_shape = CollisionShape2D.new()
        planet_collision_shape.shape = CircleShape2D.new()
        planet_collision_shape.shape.radius = 27.5
        planet_collision_shape.position = Vector2(planet._coord[0], planet._coord[1])
        add_child(planet_collision_shape)
        planet._body = planet_collision_shape
#       #planet._body.mouse_entered.connect(self._on_mouse_entered)


func _on_mouse_entered():
    print("BUMP")


func _draw():
    for planet in planets:
        var center = Vector2(planet._coord[0] + velocity.x, planet._coord[1] + velocity.y)
        var size = planet._texture.get_size()
        var rect = Rect2(center - size * .5, size)
        draw_texture_rect(planet._texture, rect, false)
        planet._body.position = center


func update():
    queue_redraw()
    

func _process(_delta):
    if Input.is_physical_key_pressed(65):
        velocity.x -= 10
    if Input.is_physical_key_pressed(68):
        velocity.x += 10
    if Input.is_physical_key_pressed(87):
        velocity.y -= 10
    if Input.is_physical_key_pressed(83):
        velocity.y += 10
    update()


func _input(event: InputEvent) -> void:
    if event is InputEventMouseButton:
        if event.is_pressed():
            if event.button_index == 4:
                if camera.zoom > zoom_minimum:
                    camera.zoom -= zoom_speed
                    print(camera.zoom)
            if event.button_index == 5:
                if camera.zoom < zoom_maximum:
                    camera.zoom += zoom_speed
                    print(camera.zoom)

At runtime the tree looks as follows:

enter image description here

The above code results in planet icons that move with their respective collision shapes as follows:

enter image description here

Since I have the collision shapes where I want them I belived that my next step was to connect a signal so I could tell when the mouse was over a planet. Any advice on how to proceed from here greatly appreciated.


Solution

  • As you have surely already figured out by the errors, neither CollisionShape2D nor CircleShape2D. We can approach this a few ways.


    The easy Godot way™

    You would make a Planet scene, with these Nodes

    Planet
    ├ CollisionShape2D
    └ Sprite2D
    

    The root of the scene (i.e. Planet) would be a StaticBody2D (or perhaps a some other kind of body), and it would have the mouse_entered signal.

    The CollisionShape2D would have the CircleShape2D you want set from the editor. But don't position it. You will position the StaticBody2D instead.

    And the Sprite2D would be responsible for rendering the texture. So with this approach would not be using _draw to render the textures.

    Since both the CollisionShape2D and Sprite2D are children of the StaticBody2D (Planet), when it moves they move with it.

    Once you have the scene, it would be saved to a scene file (e.g. a ".tscn" file) which you can instantiate multiple times, and set its properties (change its position and change the texture of the Sprite2D) either from code or in the editor (you can set editable children from the context menu in the scene panel, which will allow you to change the Sprite2D texture).

    So, as you can see this approach requires no code for the planets.


    The hard Godot way™

    Using _draw has its advantages. For once you don't need to instantiate as many objects (since you don't have any Sprite2D).

    But what about physics? That is the issue here… You don't have a physics body that can do a point-shape intersection/collision which the mouse signals need…

    So, you could do this the hard way. We can get mouse input events in _input and check if they enter any planet. Thankfully you only need point-circle collision, which is easy. It would be something like this:

    func _input(event:InputEvent) -> void:
        if not event is InputEventMouse:
            return
    
        var mouse_event := event as InputEventMouse
    
        for planet in planets:
            var center = Vector2(planet._coord[0] + velocity.x, planet._coord[1] + velocity.y)
            var radius = planet._texture.get_size().x * 0.5 # 27.5
            if mouse_event.position.distance_to(center) < radius:
                 # the mouse pointer is over the planet
                 break
    

    If you want actual signals you can do it like this:

    signal mouse_entered()
    signal mouse_exited()
    
    
    var _mouse_is_inside:bool
    
    func _input(event:InputEvent) -> void:
        if not event is InputEventMouse:
            return
    
        var mouse_event := event as InputEventMouse
    
        var is_inside := false
        for planet in planets:
            var center = Vector2(planet._coord[0] + velocity.x, planet._coord[1] + velocity.y)
            var radius = planet._texture.get_size().x * 0.5 # 27.5
            if mouse_event.position.distance_to(center) < radius:
                 is_inside = true
                 break
    
        if _mouse_is_inside != is_inside:
            _mouse_is_inside = is_inside
            if _mouse_is_inside:
                emit_signal("mouse_entered")
            else:
                emit_signal("mouse_exited")
    

    The harder Godot way™

    If the motivation to use _draw is optimization. Then we can do much better than the approaches above. And we do that by using the RenderingServer for drawing and the PhysicsServer2D et.al. for physics. In other words, we can use a lower level API. This approach does not use Nodes for the planets at all. You probably don't need this unless you will have planets in the thousands.

    We can create a physics body like this:

    body=PhysicsServer2D.body_create()
    

    And you are responsible for freeing it like this:

    PhysicsServer2D.free_rid(body)
    

    My recommendation is to use _enter_tree for initialization, and _exit_tree for tear down.

    You need to assign a space to your body, so we use the one from your Node2D (where the script is running):

    PhysicsServer2D.body_set_space(body, get_world_2d().space)
    

    And you want to give a shape to the body, which is something like this (here shape would be the CircleShape2D):

    var disabled := false
    PhysicsServer2D.body_add_shape(body, shape.get_rid(), Transform2D.IDENTITY, disabled)
    

    If you prefer to not use CircleShape2D, you can use circle_shape_create and configure it with shape_set_data:

    var radius := 27.5
    var shape_rid := PhysicsServer2D.circle_shape_create()
    PhysicsServer2D.shape_set_data(shape_rid, radius)
    

    To position the body you do this:

    PhysicsServer2D.body_set_state(body, PhysicsServer2D.BODY_STATE_TRANSFORM, global_transform)
    

    And for the visual part, create a canvas item:

    var ci_rid := RenderingServer.canvas_item_create()
    

    We are going to make it a child of your Node2D (where the script is running):

    RenderingServer.canvas_item_set_parent(ci_rid, get_canvas_item()) 
    

    We can load our Texture and set it:

    var texture:Texture = load("res://icon.png")
    texture.reference()
    var texture_rid := texture.get_rid()
    

    This is intended to be a workaround to prevent Texture being freed due not being references to it, and thus invalidating the rid. Another way to go about it is to make a copy:

    var texture:Texture = load("res://icon.png")
    var image:Image = texture.get_data()
    var texture_rid := RenderingServer.texture_2d_create(image)
    

    And you can add the texture, and of course position the canvas item:

    RenderingServer.canvas_item_add_texture_rect(ci_rid, rect, texture_rid)
    RenderingServer.canvas_item_set_transform(ci_rid, Transform2D.IDENTITY)
    

    And you can keep calling PhysicsServer2D.body_set_state and RenderingServer.canvas_item_set_transform to move the planet.

    Ah, and you need to know if the mouse enters… Once again we will resource to _input, this time we use intersect_point to find what is below the mouse pointer, which gives us an Array of objects, and we look for one that matches the rid of our body. If we find it then the mouse pointer is inside:

    signal mouse_entered()
    signal mouse_exited()
    
    
    var _mouse_is_inside:bool
    
    
    func _input(event: InputEvent) -> void:
        if not event is InputEventMouse:
            return
    
        var mouse_event := event as InputEventMouse
    
        var viewport := get_viewport()
        var position:Vector2 = viewport.get_canvas_transform().affine_inverse() * mouse_event.position
        var query := PhysicsPointQueryParameters2D.new()
        query.position = position
        var objects := get_world_2d().direct_space_state.intersect_point(query, 32)
        var is_inside := false
        for object in objects:
            if object.rid == body:
                is_inside = true
                break
    
        if _mouse_is_inside != is_inside:
            _mouse_is_inside = is_inside
            if _mouse_is_inside:
                emit_signal("mouse_entered")
            else:
                emit_signal("mouse_exited")
    

    Note: this code is adapted from Godot 3 to 4 because I haven't got that deep into Godot 4 yet. If it gives you problems let me know.