lenec ru

← все посты

Godot 4 signals: как не утонуть в связях между нодами

16K

Сигналы в Godot — одна из тех штук, которые в начале кажутся очевидными, а через полгода большого проекта превращаются в спагетти. Кто на что подписан, кто эмитит, что упало в _exit_tree — отслеживать руками невозможно. Я прошёл через эту фазу на двух пет-проектах, и расскажу, какие паттерны работают, а какие — приятная иллюзия чистой архитектуры.

Ориентируюсь на Godot 4.3, GDScript 2 со статической типизацией. Часть советов работает и для C#, отдельно отмечу различия.

Сначала — что такое сигналы и зачем они тебе

Сигнал — это типизированное событие, которое нода может эмитить, а другие ноды — слушать. Это встроенный механизм Observer'а, без сторонних библиотек и без боли с ручными списками подписчиков.

Объявление и эмит:

signal health_changed(new_value: int, max_value: int)
signal died

var hp: int = 100
var max_hp: int = 100

func take_damage(amount: int) -> void:
    hp = max(0, hp - amount)
    health_changed.emit(hp, max_hp)
    if hp == 0:
        died.emit()

Подписка из другого скрипта:

func _ready() -> void:
    $Player.health_changed.connect(_on_player_health_changed)
    $Player.died.connect(_on_player_died)

func _on_player_health_changed(hp: int, max_hp: int) -> void:
    $UI/HealthBar.value = float(hp) / float(max_hp)

func _on_player_died() -> void:
    $UI/GameOverScreen.show()

Зачем это вообще нужно, когда можно вызвать $UI/HealthBar.update(hp) напрямую? Потому что в момент написания Player ты не знаешь, кому он будет нужен. Потом появится система достижений, статистика, аналитика, экран death-replay'а — и каждый раз лезть в код игрока добавлять новый вызов вы не хотите. Сигнал — это контракт «вот что я делаю», а кто его слушает — не моё дело.

Локальные сигналы vs глобальные

Главный архитектурный выбор — сигнал на конкретной ноде или на глобальной шине.

Локальные сигналы — на самой ноде, например на враге health_changed. Подписаться может только тот, у кого есть ссылка на ноду.

Глобальная шина — autoload-синглтон, который держит все сигналы уровня игры:

extends Node

signal player_died
signal enemy_killed(enemy_type: String, position: Vector2)
signal coin_picked_up(amount: int)
signal level_changed(new_level: int)

В Project Settings → Autoload добавляешь этот скрипт под именем Events. Дальше из любой ноды:

Events.coin_picked_up.emit(10)

А подписаться может кто угодно, не имея прямой ссылки.

Соблазн запихать всё в Events огромный. На втором месяце все события переезжают туда, потому что «удобнее же». Через полгода ты смотришь на 80 сигналов в одном файле, не понимаешь, где что эмитится, и каждое изменение требует grep по всему проекту.

Я держу в Events только то, что реально кросс-системно. Игрок умер, уровень сменился, игра поставлена на паузу. Если сигнал нужен внутри одной системы (UI враг — UI'ный хеалтбар) — это локальный сигнал. Правило простое: если для подписчика логично знать про эмиттер, держи сигнал у эмиттера. Если нет — глобальная шина.

Awaiting сигнала вместо подписки

Полезный приём, который не все знают: можно await'ить сигнал, и код приостановится до его эмита.

func play_intro() -> void:
    $Cutscene.start()
    await $Cutscene.finished
    $Player.process_mode = Node.PROCESS_MODE_INHERIT
    show_tutorial_hint()

Очень удобно для линейных сценариев: катсцены, обучение, переходы между уровнями. Никаких state-машин на три состояния и колбэков с замыканиями — пишешь как обычный скрипт.

Минус: если эмит не случится (нода удалилась, сцена сменилась), await зависнет. Решается таймаутом через get_tree().create_timer(timeout).timeout и await Promise.race-стайл паттерны через библиотеку или ручную реализацию.

Параметры в сигнале — будь скуп

Соблазн прокинуть в сигнал «всё, что может понадобиться» — естественен. Не делай. Сигнал с пятью параметрами — это плохой контракт.

Вместо

signal enemy_killed(enemy: Node, position: Vector2, killer: Node, weapon: Resource, exp: int, drop_table: Array)

сделай

class_name KillContext
extends RefCounted

var enemy: Node
var killer: Node
var weapon: Resource
var position: Vector2
var exp: int
var drop_table: Array

signal enemy_killed(ctx: KillContext)

Подписчики получают один объект, и если позже понадобится добавить поле — не нужно менять сигнатуру сигнала и каждое место подписки. Это особенно важно для сигналов в Events: их слушает много кода, рефакторить дорого.

Connect и disconnect: где вы оба теряете память

Если нода-эмиттер живёт дольше слушателя, и слушатель удалится без disconnect — у тебя утечка ссылок. Не «утечка памяти» в JVM-смысле (Godot RefCounted рулит), но логическая: эмиттер всё ещё думает, что слушатель жив, и при следующем эмите выбросит warning.

В простых случаях движок справится сам через слабые ссылки на ноды. Но если ты подписал лямбду или метод от RefCounted — берёшь на себя ответственность.

Универсальный совет: подписку делай в _ready, отписку — в _exit_tree:

var _player_health_callback: Callable

func _ready() -> void:
    _player_health_callback = _on_player_health_changed
    Events.player_health_changed.connect(_player_health_callback)

func _exit_tree() -> void:
    Events.player_health_changed.disconnect(_player_health_callback)

Боль в том, что забыть отписаться легко. Я завёл вспомогательный класс:

class_name SignalBus
extends RefCounted

var _connections: Array[Dictionary] = []

func bind(signal_obj: Signal, callback: Callable) -> void:
    signal_obj.connect(callback)
    _connections.append({"signal": signal_obj, "callback": callback})

func unbind_all() -> void:
    for c in _connections:
        if c.signal.is_connected(c.callback):
            c.signal.disconnect(c.callback)
    _connections.clear()

В ноде:

var _bus: SignalBus = SignalBus.new()

func _ready() -> void:
    _bus.bind(Events.player_died, _on_player_died)
    _bus.bind(Events.coin_picked_up, _on_coin_picked_up)

func _exit_tree() -> void:
    _bus.unbind_all()

Не идеальная защита, но дисциплинирует.

Connect flags: одноразовая подписка

В Godot 4 у connect есть флаги. Самый полезный:

$Player.died.connect(_on_player_died, CONNECT_ONE_SHOT)

Сигнал отвалится автоматически после первого срабатывания. Идеально для «когда уровень загрузится — покажи туториал», «когда враг умрёт — выбрось лут». Не нужно вручную чистить.

Ещё есть CONNECT_DEFERRED — обработчик вызовется в следующем idle-фрейме, а не сразу. Полезно, когда обработчик меняет состояние сцены, и ты хочешь, чтобы текущий вызов сначала спокойно завершился.

Сигналы и State Machine

Я долгое время делал стейт-машины врагов через прямые вызовы методов между состояниями. Получалось грязно: одно состояние знает про следующее, кросс-связи разрастаются. Перевёл на сигналы — стало читаемо.

class_name State
extends Node

signal transition_requested(target_state: String)

func enter() -> void: pass
func exit() -> void: pass
func tick(delta: float) -> void: pass

Конкретное состояние:

class_name PatrolState
extends State

@export var enemy: CharacterBody2D

func tick(delta: float) -> void:
    if _player_in_sight():
        transition_requested.emit("chase")
        return
    enemy.velocity = _patrol_direction() * 50
    enemy.move_and_slide()

State-machine только переключает состояния по сигналу, и больше ни о чём не знает:

class_name StateMachine
extends Node

@export var initial_state: String
var _current: State
var _states: Dictionary = {}

func _ready() -> void:
    for child in get_children():
        if child is State:
            _states[child.name.to_lower()] = child
            child.transition_requested.connect(_change_state)
    _change_state(initial_state)

func _change_state(target: String) -> void:
    if _current:
        _current.exit()
    _current = _states[target]
    _current.enter()

func _process(delta: float) -> void:
    if _current:
        _current.tick(delta)

Не панацея, но позволяет добавлять новые состояния не трогая существующие. Каждое состояние — отдельный файл и отдельный сигнал-эмиттер. Тестировать тоже проще: подменяешь зависимости, прогоняешь сценарий.

Сигналы в C#: что отличается

В C# сигналы — это делегаты с атрибутом [Signal]:

public partial class Player : CharacterBody2D
{
    [Signal]
    public delegate void HealthChangedEventHandler(int newValue, int maxValue);

    [Signal]
    public delegate void DiedEventHandler();

    public void TakeDamage(int amount)
    {
        _hp = Mathf.Max(0, _hp - amount);
        EmitSignal(SignalName.HealthChanged, _hp, _maxHp);
        if (_hp == 0)
            EmitSignal(SignalName.Died);
    }
}

Подписка:

_player.HealthChanged += OnPlayerHealthChanged;
_player.Died += OnPlayerDied;

Через += подписка типобезопасная и проверяется компилятором, в отличие от GDScript-овского connect со строковыми именами под капотом. Минус: в редакторе через инспектор подключать неудобно, и hot reload работает капризно.

В C# часто пишут собственные C#-евенты на чистых классах вместо Godot-сигналов, особенно для не-Node классов (RefCounted, Resource). Работает быстрее и проще, но теряешь интеграцию с редактором.

Где сигналы — не лучший выбор

Сигналы — observer-паттерн. У него есть стоимость:

  • Дебаг сложнее. Стектрейс показывает emit, но логика того, кто слушает, разбросана по проекту. На complex проекте я держу одну глобальную функцию Events.log_emit("event_name", args), которую вызываю перед эмитами критичных событий, чтобы было понятно, что произошло.
  • Порядок не гарантирован. Если на сигнал подписаны три ноды, в каком порядке они получат вызов — деталь реализации. Не строй на этом логику.
  • Возвраты не работают. Сигнал ничего не возвращает. Если нужно «спросить, можно ли это сделать» — сигнал не подходит, бери прямой вызов или Resource-based callback.

В этих случаях я обычно делаю прямой вызов с интерфейсом или ScriptableObject-аналогом через Resource.

Что я рекомендую держать в голове

Сигналы — клей, который помогает держать ноды независимыми. Но клей тоже накапливается, и в большом проекте без дисциплины ты получишь паутину «кто что эмитит» вместо архитектуры.

Минимум, который окупается: глобальная шина Events только для кросс-системных событий, локальные сигналы на нодах для внутренних, всегда пара _ready/_exit_tree для подписки и отписки, контекст-объекты вместо длинных списков параметров. И log в одном месте — спасает на третьей итерации, когда ничего не понятно.

Следующее, что стоит почитать — раздел про сигналы в официальной документации Godot и главу Best practices: scenes versus scripts и autoloads versus regular nodes. Это сэкономит пару рефакторингов.

Комментарии 0

  • Будьте первым, кто оставит комментарий.

Войдите, чтобы оставить комментарий.