Property proxying in Godot
Mon Oct 09 2023
Probably, a typical scenario in Godot is where you have a scene that consists of multiple nodes of various kinds with the intention of reuse.
For our example, let us use a theoretical scene that has a Sprite2D and a Label as their children. We could use that to display a list of inventory items in our game.
Probably the two main things you would want to change are the label text and the texture of the sprite.
One way you could reuse this scene is with inherited scenes (Scene → New Inherited Scene), but then you would be creating an inherited scene for every inventory item, and, especially in a game with a lot of items, that would be a lot of time better spent elsewhere, and it would also complicate your scripts since for every inventory item you would have a separate preload()
or load()
call each scene and so on.
Another way could be to work with "Editable Children" of the InventoryItem
instances from a main inventory scene, but I also don't think that this approach is very good since you expose the "internals" of your reusable scene to a scene that should have no business seeing what's under the hood of your InventoryItem
.
What we want to do is provide a clean interface to the consumers of our InventoryItem
scene, so parent scenes know what they can change without breaking stuff. We will achieve that with property proxying, which is a quite common pattern. What that means is that we will combine GDScript exports with setters, and as a result, our InventoryItem
will have properties that, upon change, will also update the properties of our sprite and label. This way, we will have a highly reusable and potentially limitlessly customizable scene without having to create inherited scenes or edit children of instantiated scenes.
How?
Lets start with the groundwork - attach a script to the InventoryItem
node, pick "Object: Empty" as the template, and press "Create".
Write the following:
@tool
extends Node2D
@export var text: String:
set = set_text
@export var texture: Texture2D:
set = set_texture
func set_text(value: String) -> void:
pass
func set_texture(value: Texture2D) -> void:
pass
We define two exported properties, meaning that we can not only change them from code but also assign them through the Godot inspector:
We use text
of type String
, since we will set the property text
of our Label
, and we use texture
of type Texture2D
since we will set the texture
property of our Sprite2D
. It isn't important that the property names match; I think it's just simpler to read and understand that way, but property names should be relevant to the purpose of the scene, so item_name
instead of text
and portrait
instead of texture
could work better.
We define two (for now, empty) setter functions for them. Setters allow us to run logic when a property is set.
Finally, a @tool
keyword at the top tells the script to run in the editor, so we can instantly see the results of our properties being set.
Now, lets implement the setters themselves, which is the crucial part.
@onready var sprite: Sprite2D = $Sprite2D
@onready var label: Label = $Label
func set_text(value: String) -> void:
text = value
if not label:
return
label.text = value
func set_texture(value: Texture2D) -> void:
texture = value
if not sprite:
return
sprite.texture = value
First, we store references of our child nodes. The $
syntax is a nice syntax-sugarry way to fetch nodes by path, but if you plan on moving them around, I recommend using unique nodes so your script doesn't break if you decide to move the nodes into a container.
Both setters themselves are pretty much doing the same thing: they set the value of the property itself, stop executing if the reference to a child node does not exist yet, but if it does, set the property of the child. Already now, if you play around with the exported values, you can see the changes right away, as if you were editing the children directly:
So, you add child instances of InventoryItem
to your main scene, set their properties, run the scene, and get a blank screen. WTF?
There's one final thing we're missing: if we set these properties on instantiation or the inspector instead of later on during the runtime, the setters get executed before the child components exist, so we unfortunately have to call them explicitly in the _ready()
function, which is some extra bookkeeping we have to do to reap the benefits of this pattern. Together with that, our final script looks like this:
# inventory_item.gd
@tool
extends Node2D
@export var text: String:
set = set_text
@export var texture: Texture2D:
set = set_texture
@onready var sprite: Sprite2D = $Sprite2D
@onready var label: Label = $Label
func _ready() -> void:
# call setters explictly so children get the initial values
set_text(text)
set_texture(texture)
func set_text(value: String) -> void:
text = value
if not label:
return
label.text = value
func set_texture(value: Texture2D) -> void:
texture = value
if not sprite:
return
sprite.texture = value
Script example
As you probably know, we don't have to use the inspector at all, and we can set the proxied properties programmatically:
const InventoryItem = preload("res://inventory_item.tscn")
func _ready() -> void:
# assuming we have a theoretical inventory array
for item in inventory:
var item := InventoryScene.instantiate()
item.text = item.name
item.texture = load(item.icon_path)
add_child(item)
And that's it. Of course, the InventoryItem
scene is a quite basic example, but it should serve as a nice and extendable basis to create lots of flexible and reusable scenes for your games.
If you see any typos, syntax errors, or other nonsense, don't hesitate to ping me! That said, thanks to @cpt_manlypink for pointing me to a couple mistakes!