godotengine / godot-proposals

Godot Improvement Proposals (GIPs)
MIT License
1.14k stars 93 forks source link

Add static signals in GDScript #6851

Open 4d49 opened 1 year ago

4d49 commented 1 year ago

Describe the project you are working on

Strategy game.

Describe the problem or limitation you are having in your project

I have a singleton with many signals for different things. Sometimes a singleton can contain signals that should be moved elsewhere. After adding static variables, it would be nice to add static signals as well. This will create helper objects that do not need to be instantiated. These "objects" can be used as singletons.

Describe the feature / enhancement and how it helps to overcome the problem or limitation

We just connect/disconnect anywhere. We don't need to have an instance of the class.

func _enter_tree() -> void:
    ClassName.signal_name.connect(_on_method)

func _exit_tree() -> void:
    ClassName.signal_name.disconnect(_on_method)

Describe how your proposal will work, with code, pseudo-code, mock-ups, and/or diagrams

An example of a class with a static signal:

# selected_cell.gd
class_name SelectedCell

static signal selected_cell_changed(cell: Cell)

static var selected_cell: Cell = null:
   set = set_selected_cell

static func set_selected_cell(n_selected_cell: Cell) -> void:
    if is_same(selected_cell, n_selected_cell):
        return

    selected_cell = n_selected_cell
    selected_cell_changed.emit(n_selected_cell)

Sample code that uses a static signal:

# other_script.gd
func _ready() -> void:
    # We don't need an object instance to use it.
    SelectedCell.selected_cell_changed.connect(_on_selected_cell_changed)

func _on_selected_cell_changed(cell: Cell) -> void:
    print(cell)

If this enhancement will not be used often, can it be worked around with a few lines of script?

Yes, it is potentially possible to use a static variable with a signal:

# selected_cell.gd
class_name SelectedCell

static var signal : Signal

But then it still requires an "entry point" to assign that signal:

# other_script.gd
signal real_signal(cell: Cell)

func _init() -> void:
    SelectedCell.signal = real_signal

Is there a reason why this should be core and not an add-on in the asset library?

It's part of GDScript.

dalexeev commented 1 year ago

Only instances can have signals, not classes. But a GDScript class is an instance of the GDScript class, so probably it's possible to implement this without even adding static signals to the core (but need support in DocData for user documentation).

Also note that if the class is unloaded (if the script has an @static_unload annotation and no references to the script left), then connections will probably be lost as well.

timothyqiu commented 1 year ago

Another workaround is to use the event bus pattern: define these signals in a dedicated autoload script for all static signals.

# EventBus.gd

signal selected_cell_changed(cell: Cell)

# selected_cell.gd
class_name SelectedCell

static var selected_cell: Cell = null:
    set = set_selected_cell

static func set_selected_cell(n_selected_cell: Cell) -> void:
    if is_same(selected_cell, n_selected_cell):
        return

    selected_cell = n_selected_cell
    EventBus.selected_cell_changed.emit(n_selected_cell)

# other_script.gd
func _ready() -> void:
    # We don't need an object instance to use it.
    EventBus.selected_cell_changed.connect(_on_selected_cell_changed)

func _on_selected_cell_changed(cell: Cell) -> void:
    print(cell)

The downside is you have to name the signals carefully.

L4Vo5 commented 1 year ago

It would be interesting if all signals could double as static signals. Then you could choose to either connect to a particular instance's signals as usual, or instead connect to the whole class' signal to receive it when any instance emits it. For example my_enemy.poisoned.connect(...) for the first and Enemy.poisoned.connect(...) for the second. When an instance emits a signal it'd call all the connected functions for its own signal as well as the functions connected to the class' signal. Static functions would only emit the class' signal.

theraot commented 1 year ago

Allowing to connect signals to autoloads (sigletons) (See https://github.com/godotengine/godot-proposals/issues/1694 and https://github.com/godotengine/godot-proposals/issues/4993) for the good it would do※ has a hurdle: When a scene is instantiated it is not in the scene tree, so it cannot reach autoload (singletons) at that moment. Which means such connection would have to be delayed. Perhaps this is desirable?

An alternative is to have static signals, which is what is being proposed here. Since static signals would not depend on the scene tree, then connecting automatically to them when a scene is instantiated would be viable. That would, of course, require different proposals to the linked ones to allow connected them visually from the editor, which is what I'm interested in.

I am interested in this in particular as a way to ease addons communicate with each other, without adding dependencies between them, and while the designer stays in control.


※: These are some of the situations where we would rather connect visually to a signal bus (event bus) instead of doing it from a script:

Shadowblitz16 commented 1 year ago

How would this even work?

The only why you can access static members is with a class name or a ugly preload And since class name doesn't bind to the scene your basically just accessing the script with nothing to operate on

DasGandlaf commented 1 year ago

Instead of signals, you can now (since 4.1 I believe) have a static array which saves the listening objects. e.g.

class_name Settings

static var listeners: Array

static func on_change_sub(object: Object): # To subscribe, usage: Settings.on_change(self)
    listeners.append(object)

static func on_change_unsub(object: Object): # To unsubscribe, usage: Settings.on_change_unsub(self)
    listeners.remove_at(save_listeners.find(object))

static func invoke():
    for listener in listeners:
       listener.onChange()

Dont forget to unsubscribe nodes that get freed, or else it will try to call the method on a null instance.

gokiburikin commented 1 year ago

You can functionally utilize static signals (at least how I want to use them) now using a static var singleton pattern (for the instance), though it does feel hacky. Might be side effects to doing it this way?

# settings.gd
class_name Settings extends RefCounted

static var singleton := Settings.new()
signal _changed
static var changed:Signal:
    get: return singleton._changed

static func change( key:StringName, value ) -> void:
    singleton.set( key, value )
    Settings.changed.emit( key, value )

static var example_property := "foo"
# test.gd
func _ready() -> void:
    print( Settings.example_property )
    Settings.changed.connect( func( key:String, value ):
        print( "%s changed to %s" % [key, value] ))
    Settings.change( "example_property", "bar" )
    print( Settings.example_property )
# output
foo
example_property changed to bar
bar

Static variables aren't currently auto-completed though so Settings.singleton.example_property for that.

theraot commented 1 year ago

I want to point out somebody figured out how to bind signals to a class and consume them using the expected syntax: https://stackoverflow.com/a/77026952/402022

What they are doing is adding - during initialization - a signal to the script using add_user_signal, creating a Signal object from it, and storing it in an static var.

The static signal is then consumed by accessing the static var from the script using the class name, and calling emit or connect which are available since the static var is a Signal.

That works today, and I have used it successfully. I hope this clears any doubt of how static signals might work.

TheJehoiada commented 7 months ago

I found this to be an easy workaround...

extends Node3D
class_name Drone

signal _real_signal
static var singleton := Drone.new()
static var static_signal:= Signal(singleton._real_signal)

Example use below

func _ready() -> void:
    connect_signal()
    emit()

func connect_signal():
    static_signal.connect(Callable(self, "getting_signal"))

func getting_signal():
    print("we got the signal")

func emit():
    print("emiting signal")
     Drone.static_signal.emit()
Calinou commented 7 months ago

@TheJehoiada PS: Code blocks should use triple backticks like this (with an optional language name for syntax highlighting):

```gdscript code here ```

I edited your post accordingly, but remember to do this in the future :slightly_smiling_face:

neatsketched commented 1 month ago

Just wanted to post that I have found a more concise workaround for static signals, based on some of the other solutions that have been posted before.

I created a new class that exists as a quick creator for static signals, and then you can refer to this function from anywhere:

extends Object
class_name StaticSignal

static var static_signal_id: int = 0

static func make() -> Signal:
        var signal_name: String = "StaticSignal-%s" % static_signal_id
    var owner_class := (StaticSignal as Object)
    owner_class.add_user_signal(signal_name)
    static_signal_id += 1
    return Signal(owner_class, signal_name)

Here is an example of creating a static signal from this method:

static var example_signal: Signal = StaticSignal.make()

From here, you can use example_signal as you would expect with no other drawbacks that I know of.