The proper way of adding a custom node with a built in child?

Godot Version

4.7.stable

Question

What is the proper way to add a node that already comes with a built in child that the user cannot touch?

I know the reason for internal children to exist is precisely that use case, but how do I get the child once it’s in the scene? And what about setters and getters for its properties?

I am specifically trying to add a StaticBody2D that already sets a CollisionShape2D and its shape automatically as it’s added to the scene. I tried the following:

@tool
extends StaticBody2D
class_name Platform

@export var shape: CollisionShape2D
	#set(value):
		#pass

@export var shape_size: Vector2 = Vector2(1000.0, 50.0):
	set(value):
		if not shape:
			shape = get_child(0, true)
			#shape.owner = self
		shape_size = value
		shape.shape.size = value
	get:
		if not shape:
			shape = get_child(0, true)
			#shape.owner = self
		shape_size = shape.shape.size
		return shape.shape.size
@export var one_way_collision: bool = false:
	set(value):
		if not shape:
			shape = get_child(0, true)
			#shape.owner = self
		one_way_collision = value
		shape.one_way_collision = value
	get:
		if not shape:
			shape = get_child(0, true)
			#shape.owner = self
		one_way_collision = shape.one_way_collision
		return shape.one_way_collision
@export var one_way_direction: Vector2 = Vector2.UP:
	set(value):
		if not shape:
			shape = get_child(0, true)
			#shape.owner = self
		one_way_direction = value
		shape.one_way_collision_direction = value
	get:
		if not shape:
			shape = get_child(0, true)
			#shape.owner = self
		one_way_direction = shape.one_way_collision_direction
		return shape.one_way_collision_direction

func _enter_tree() -> void:
	if Engine.is_editor_hint():
		if not get_parent() is PlatformGroup or not get_parent().get_parent() is PlatformGroup:
			printerr("Adicione plataformas apenas em grupos de plataformas.")
			queue_free()
		if get_child_count() == 0:
			var child := CollisionShape2D.new()
			child.name = "Collision"
			child.shape = RectangleShape2D.new()
			child.shape.size = Vector2(500, 50)
			child.add_to_group("2DGizmoDraw", true)
			add_child(child, true, Node.INTERNAL_MODE_FRONT)
			#child.owner = get_tree().edited_scene_root
			#child.owner = get_parent().get_parent()
			child.owner = self
			shape = child
		else:
			shape = get_child(0, true)

What am I doing too wrong?

You’ve got a lot of code for what you described. Also, your naming convention is a bit confusing. Your CollisionShape2D is called shape, but it also has a member variable called shape. Then you are doing a LOT of checks to see if shape exists in every setter and getter. You are also exporting your CollisionShape2D as a variable that needs to be linked, and then searching for a child node that you would have to create anyway for it to exist. Finally, you are adding the CollisionShape2D every time you enter the tree. So let’s simplify.

Header and CollisionShape2D

@tool
class_name Platform extends StaticBody2D

var collision_shape_2d: CollisionShape2D

First, this is a @tool script. Second, following the GDScript Style Guide, class_name should be on the same line, and before extends.

We also need a reference to your hidden CollisionShape2D. So we will make it a standard class variable.

_init()

func _init() -> void:
	collision_shape_2d = CollisionShape2D.new()
	add_child(collision_shape_2d)
	collision_shape_2d.shape = RectangleShape2D.new()

During Node initialization, we can create and add nodes to it. We do so in the _init() function. Because we are doing this here, we always know we have a collision_shape_2d, and we know it will only be created once, when the object is first created.

After saving this file, we need to reload the project so the @tool script gets recognized.

Now when we create it in the editor, by using Add Node

We get this:

shape_size

Next up, we can greatly simplify the shape_size variable declaration. All we need to do is set the proxy variable, and set the collision_shape_2d.shape.size. since the only access to that value is through this variable, we don’t need a custom getter at all. and since we’ve ensured collision_shape_2d (previously shape) always exists, we can get rid of that test.

## The size of the [RectangleShape2D] automatically created for this
## [StaticBody2D].
@export var shape_size: Vector2 = Vector2(1000.0, 50.0):
	set(value):
		shape_size = value
		collision_shape_2d.shape.size = shape_size

Since this is an @export variable, the setter is not going to run, until and unless we change the value, but we want the value to be set when the object is created in initialization. So we also add a line to _init():

collision_shape_2d.shape.size = shape_size
Full code so far.
@tool
class_name Platform extends StaticBody2D

## The size of the [RectangleShape2D] automatically created for this
## [StaticBody2D].
@export var shape_size: Vector2 = Vector2(1000.0, 50.0):
	set(value):
		shape_size = value
		collision_shape_2d.shape.size = shape_size

var collision_shape_2d: CollisionShape2D


func _init() -> void:
	collision_shape_2d = CollisionShape2D.new()
	add_child(collision_shape_2d)
	collision_shape_2d.shape = RectangleShape2D.new()
	collision_shape_2d.shape.size = shape_size

Now if we delete our node, and re-add it…

(If you don’t see this change, do a quick reload of the project.)

one_way_collision

The other two values are going to be exactly the same as the first @export variable.

## Sets whether this collision shape should only detect collision on one side
## (top or bottom).
##Note: The one way collision direction can be configured by setting
## one_way_collision_direction.
@export var one_way_collision: bool = false:
	set(value):
		one_way_collision = value
		collision_shape_2d.one_way_collision = one_way_collision
## The direction used for one-way collision.
@export var one_way_collision_direction: Vector2 = Vector2.UP:
	set(value):
		one_way_collision_direction = value
		collision_shape_2d.one_way_collision_direction = one_way_collision_direction

And two more lines to the _init():

collision_shape_2d.one_way_collision = one_way_collision
collision_shape_2d.one_way_collision_direction = one_way_collision_direction

_enter_tree()

Finally, we can simplify your _enter_tree() function greatly. You no longer need to create or search for the CollisionShape2D or RectangleShape2D. and while I wouldn’t do what you’re doing with the groups, I’m leaving that there.

func _enter_tree() -> void:
	if Engine.is_editor_hint():
		if not get_parent() is PlatformGroup or not get_parent().get_parent() is PlatformGroup:
			printerr("Adicione plataformas apenas em grupos de plataformas.")
			queue_free()

If you want the default shape to be 500 pixels wide instead of 1,000, just change the default on the @export variable. If you want to set the owner, you can, but there’s no need to do so. But add it to _init(). You also do not need to force a readable name, because this node is not visible anywhere, and you don’t need to set Node.INTERNAL_MODE_FRONT, unless you run into problems with a recursion function. If you really want to change it, do so in _init(), but I would recommend NOT doing it unless you need it to make something work. Otherwise it could cause you a bug that’s really hard to track down later on.

Final Code

@tool
class_name Platform extends StaticBody2D

## The size of the [RectangleShape2D] automatically created for this
## [StaticBody2D].
@export var shape_size: Vector2 = Vector2(1000.0, 50.0):
	set(value):
		shape_size = value
		collision_shape_2d.shape.size = shape_size
## Sets whether this collision shape should only detect collision on one side
## (top or bottom).
##Note: The one way collision direction can be configured by setting
## one_way_collision_direction.
@export var one_way_collision: bool = false:
	set(value):
		one_way_collision = value
		collision_shape_2d.one_way_collision = one_way_collision
## The direction used for one-way collision.
@export var one_way_collision_direction: Vector2 = Vector2.UP:
	set(value):
		one_way_collision_direction = value
		collision_shape_2d.one_way_collision_direction = one_way_collision_direction

var collision_shape_2d: CollisionShape2D


func _init() -> void:
	collision_shape_2d = CollisionShape2D.new()
	add_child(collision_shape_2d)
	collision_shape_2d.shape = RectangleShape2D.new()
	collision_shape_2d.shape.size = shape_size
	collision_shape_2d.one_way_collision = one_way_collision
	collision_shape_2d.one_way_collision_direction = one_way_collision_direction


func _enter_tree() -> void:
	if Engine.is_editor_hint():
		if not get_parent() is PlatformGroup or not get_parent().get_parent() is PlatformGroup:
			printerr("Adicione plataformas apenas em grupos de plataformas.")
			queue_free()

Sorry for the late response, but thanks, you taught me a lot.

Just a last question, when I was testing this, before the post, I could drag the entire platform by clicking on the collision shape, but I can’t anymore. I changed add_child(collision_shape, false, Node.INTERNAL_MODE_FRONT), but it didn’t help…

I think if you check your old version, you were not moving the whole thing, but only the CollisionShape2D. You just didn’t notice it because the StaticBody2D has no visual representation. You can check this by adding a Sprite2D node as a child of the StaticBody2D and putting a picture in it. In your old version, when you clicked and dragged, the Sprite2D would not move with the CollisionShape2D. Now, when you select the root StaticBody2D node and move it, everything moves together.