Архитектура и разработка на Godot 4.x: От узла до системы

Профессиональный курс для инженеров, охватывающий принципы Clean Code, типизацию GDScript 2.0, паттерны проектирования и оптимизацию в Godot 4.3+. Упор на создание масштабируемых систем, Data-Driven подход и глубокое понимание архитектуры движка.

Фундамент архитектуры: Экосистема узлов и типизированный GDScript 2.0

Фундамент архитектуры: Экосистема узлов и типизированный GDScript 2.0

Добро пожаловать в курс «Архитектура и разработка на Godot 4.x». Мы начинаем не с написания кода движения персонажа, а с понимания фундамента, на котором строится любой масштабируемый проект в Godot. Ошибка многих разработчиков, приходящих из Unity или Unreal Engine, заключается в попытке навязать движку чуждые ему паттерны. Godot требует мышления в терминах Дерева Сцены (Scene Tree) и Сигналов.

В этой лекции мы разберем атомарную единицу движка — Узел (Node), научимся выбирать правильные инструменты для задач и превратим GDScript из скриптового языка в строгий инженерный инструмент.

Экосистема Узлов: Анатомия Godot

В основе Godot лежит объектно-ориентированная иерархия. Понимание того, от чего наследуется ваш узел, определяет его возможности и стоимость использования.

1. Object и Node

Класс Object — это база всего. Он предоставляет доступ к метаданным, скриптам и управлению памятью. Однако, Node — это первый класс, который может быть частью Дерева Сцены.

Ключевые особенности Node:

  • Имеет имя и путь в дереве.
  • Управляет жизненным циклом (_ready, _process, tree_entered).
  • Может иметь дочерние узлы.
  • Не имеет визуального представления или трансформации (позиции) в пространстве.

Используйте Node, когда вам нужен контроллер логики, менеджер данных или компонент, не привязанный к физическому месту в мире (например, StateMachine или InventoryManager).

2. CanvasItem

Абстрактный класс для всего, что отображается в 2D. Он добавляет:

  • Отрисовку (draw calls).
  • Видимость (visible).
  • Модуляцию цвета (modulate).
  • Z-индекс (порядок отрисовки).

3. Node2D vs Control

Здесь происходит главное разделение 2D-разработки.

  • Node2D: Используется для игровых объектов. Его позиция определяется трансформацией (Transform2D) — позиция, поворот, масштаб. Единицы измерения — пиксели (или метры в физическом движке).
  • Control: Используется для пользовательского интерфейса (UI). Его позиция и размер определяются якорями (Anchors) и контейнерами. Он умеет обрабатывать фокус ввода и клики мыши лучше, чем Node2D.

Никогда не используйте Node2D для создания интерфейсов и Control для создания игровых персонажей. Это приведет к проблемам с адаптивностью экрана и обработкой коллизий.

Композиция против Наследования

В классическом ООП мы часто создаем глубокие иерархии: Enemy -> GroundEnemy -> Orc -> EliteOrc. В Godot это считается анти-паттерном, если иерархия становится глубже 2-3 уровней.

Godot поощряет Композицию. Вместо того чтобы наследовать функционал, вы добавляете его как дочерний узел.

Пример: Вместо того чтобы писать код здоровья внутри скрипта Enemy.gd, создайте отдельную сцену HealthComponent (на базе Node), которая обрабатывает урон и смерть. Добавьте этот узел к игроку, врагу или разрушаемому ящику.

GDScript 2.0 как профессиональный инструмент

С выходом Godot 4.0, GDScript получил значительные улучшения производительности и безопасности типов. Использование динамической типизации (var x = 10) в крупных проектах недопустимо. Это приводит к ошибкам, которые обнаруживаются только во время выполнения, и замедляет работу автодополнения кода.

Статическая типизация

Всегда указывайте типы переменных, аргументов функций и возвращаемых значений. Это ускоряет выполнение кода (движок избегает лишних проверок типов) и делает рефакторинг безопасным.

Плохо:

func take_damage(amount):
    health -= amount
    return health <= 0

Хорошо (Clean Code):

var health: int = 100

func take_damage(amount: int) -> bool:
    health -= amount
    return health <= 0

Лямбда-функции и Callables

В Godot 4 функции являются первоклассными объектами (Callable). Лямбды позволяют писать лаконичный код для обработки сигналов или сортировки.

# Сортировка массива объектов по дистанции
enemies.sort_custom(func(a: Node2D, b: Node2D) -> bool:
    return position.distance_squared_to(a.position) < position.distance_squared_to(b.position)
)

Аннотации

Аннотации — это метаданные для движка, которые упрощают код.

  • @onready: Инициализирует переменную, когда узел входит в дерево сцены (сразу после _ready). Избавляет от громоздкого кода в функции _ready().
  • @export: Выносит переменную в Инспектор редактора. Позволяет геймдизайнерам менять параметры без правки кода.
  • @rpc: Настраивает удаленный вызов процедур для мультиплеера (будет рассмотрено в следующих модулях).

Техническое задание: Система вооружения

Рассмотрим применение теории на практике.

1. Проблема

Необходимо создать универсальную систему оружия, где разные пушки имеют разные параметры (скорострельность, урон, снаряд), но управляются единым интерфейсом.

2. Решение (The Book of Nodes style)

Мы будем использовать Композицию. Оружие будет отдельным узлом Node2D (так как у него есть позиция дула), который прикрепляется к персонажу. Логика стрельбы будет инкапсулирована в скрипте оружия, а не персонажа.

3. Реализация (The Book of Code style)

Ниже представлен эталонный код базового класса оружия с использованием строгой типизации.

class_name Weapon
extends Node2D

# Сигнал, оповещающий систему о выстреле (для UI или звука)
signal fired(ammo_remaining: int)

# Экспорт параметров для настройки в редакторе
@export_category("Weapon Stats")
@export var cooldown_time: float = 0.5
@export var max_ammo: int = 30
@export var projectile_scene: PackedScene

# Приватные переменные
var _current_ammo: int
var _can_fire: bool = true

@onready var cooldown_timer: Timer = $CooldownTimer
@onready var muzzle: Marker2D = $Muzzle

func _ready() -> void:
    _current_ammo = max_ammo
    # Использование Callable для соединения сигнала
    cooldown_timer.timeout.connect(_on_cooldown_timeout)
    cooldown_timer.wait_time = cooldown_time

func pull_trigger() -> void:
    if _can_fire and _current_ammo > 0:
        _shoot()
    elif _current_ammo <= 0:
        print("Click! No ammo.")

func _shoot() -> void:
    _can_fire = false
    _current_ammo -= 1

    if projectile_scene:
        # Инстанцирование снаряда
        var projectile: Node2D = projectile_scene.instantiate()
        # Добавляем снаряд в корень текущей сцены, чтобы он не двигался вместе с оружием
        get_tree().current_scene.add_child(projectile)

        # Математика трансформации: устанавливаем позицию и поворот
        projectile.global_position = muzzle.global_position
        projectile.global_rotation = muzzle.global_rotation

    fired.emit(_current_ammo)
    cooldown_timer.start()

func _on_cooldown_timeout() -> void:
    _can_fire = true

В этом коде мы используем простую математику для позиционирования. Однако, при расчете траектории снаряда часто используется формула движения:

P⃗new=P⃗old+V⃗⋅Δt\vec{P}_{new} = \vec{P}_{old} + \vec{V} \cdot \Delta t

Где P⃗new\vec{P}_{new} — новая позиция объекта (вектор), P⃗old\vec{P}_{old} — текущая позиция, V⃗\vec{V} — вектор скорости, а Δt\Delta t — время, прошедшее с последнего кадра (delta). Это обеспечивает плавное движение независимо от частоты кадров.

4. Предостережение

При разработке такой архитектуры можно допустить критические ошибки:

  1. Циклические зависимости (Cyclic Dependencies): Если скрипт Weapon.gd ссылается на класс Player, а Player.gd ссылается на Weapon, Godot может выдать ошибку парсинга или бесконечный цикл загрузки. Решение: используйте сигналы или абстракцию. Оружие не должно знать, кто именно из него стреляет.
  2. Утечки памяти при instantiate(): Если вы создаете снаряды и не удаляете их (через queue_free()) после того, как они улетели за экран или попали в цель, игра со временем потребит всю оперативную память. Всегда контролируйте жизненный цикл динамических объектов.

5. Лабораторная работа

Задание: Создайте сцену HealthComponent (наследуется от Node).

  1. Добавьте экспортную переменную max_health (int).
  2. Реализуйте типизированную функцию damage(amount: int).
  3. Добавьте сигнал died, который эмитится, когда здоровье падает до 0.
  4. Подключите этот компонент к простому CharacterBody2D и вызовите нанесение урона по нажатию пробела.

В следующей лекции мы углубимся в систему коммуникаций и разберем, почему Синглтоны часто являются злом в архитектуре Godot.

Коммуникация систем: Сигналы, синглтоны и паттерн State Machine

Коммуникация систем: Сигналы, синглтоны и паттерн State Machine

В предыдущих лекциях мы научились создавать изолированные компоненты. Теперь перед нами встает главная проблема архитектуры: как заставить эти компоненты общаться друг с другом, не превращая проект в «спагетти-код»? Прямые ссылки ($Player, get_parent()) — это путь к жесткой связности (tight coupling), которая делает рефакторинг невозможным.

В этой статье мы разберем три столпа коммуникации в Godot: иерархию сигналов, правильное использование глобального состояния и реализацию конечного автомата (State Machine) на узлах.

Принцип «Call Down, Signal Up»

Золотое правило архитектуры Godot звучит так: «Вызывай методы вниз, сигнализируй вверх».

  • Владелец (Parent) знает о своих детях. Он может напрямую вызывать их методы или менять свойства.
  • Ребенок (Child) не должен знать, кто его родитель. Он лишь сообщает о событиях через сигналы.

Сигналы как контракт

Сигналы в Godot 4 — это объекты первого класса. Они обеспечивают декаплинг (разрыв зависимостей). Если ваш HealthComponent эмитит сигнал died, ему все равно, кто на этот сигнал подписан: UI, гейм-менеджер или система частиц.

Пример типизированного сигнала:

# Плохо: непонятно, что передается
signal health_changed(new_value)

# Хорошо: строгая типизация аргументов
signal health_changed(new_value: int, source: Node)

Используйте await для ожидания сигналов, чтобы синхронизировать асинхронные процессы без callback-ад (callback hell):

func _play_attack_animation() -> void:
    animation_player.play("attack")
    # Ждем окончания анимации перед продолжением логики
    await animation_player.animation_finished
    _deal_damage()

Синглтоны (Autoload): Паттерн или Анти-паттерн?

Godot позволяет сделать любой скрипт или сцену глобально доступной через систему Autoload. Это реализация паттерна Синглтон.

Когда использовать Autoload

Синглтоны оправданы только для систем, которые существуют вне контекста игровой сессии или управляют глобальными ресурсами:

  1. AudioManager: Звуки не должны прерываться при смене сцен.
  2. SceneLoader: Менеджер переходов между уровнями и экранами загрузки.
  3. NetworkManager: Постоянное соединение с сервером.
  4. Settings: Хранение настроек графики и звука.

Когда Autoload ЗАПРЕЩЕН

Никогда не храните состояние игрового мира или игрока в Синглтоне.

Ошибка: Global.player_hp. Почему: Если вы захотите добавить мультиплеер (второго игрока) или сделать сохранение состояния, вам придется переписывать весь проект. Данные игрока должны храниться в экземпляре игрока или в ресурсе, привязанном к сессии.

Паттерн State Machine (Конечный Автомат)

По мере роста сложности персонажа, использование bool флагов (is_jumping, is_running, is_attacking) в _physics_process приводит к неуправляемому коду. Решение — Конечный Автомат (FSM).

В Godot FSM лучше всего реализовывать через Узлы. Каждый стан (состояние) — это отдельный узел со своим скриптом.

Техническое задание: Контроллер персонажа

Проблема: Персонаж имеет сложную логику переходов: из бега можно прыгнуть, из прыжка нельзя атаковать, при получении урона прерывается любое действие.

Решение (The Book of Nodes style): Мы создадим узел StateMachine (менеджер) и дочерние узлы Idle, Walk, Jump (состояния). Менеджер будет следить за текущим активным узлом и делегировать ему управление.

Реализация (The Book of Code style):

Сначала создадим абстрактный базовый класс для всех состояний.

# State.gd
class_name State
extends Node

# Ссылка на персонажа, которым управляет состояние
var player: CharacterBody2D

# Сигнал для перехода в другое состояние
signal transition_requested(from: State, to_state_name: String)

# Виртуальные методы, которые мы будем переопределять
func enter() -> void:
    pass

func exit() -> void:
    pass

func update(_delta: float) -> void:
    pass

func physics_update(_delta: float) -> void:
    pass

Теперь реализуем менеджер — StateMachine.

# StateMachine.gd
class_name StateMachine
extends Node

@export var initial_state: State

var current_state: State
# Словарь для быстрого доступа к узлам состояний по имени
var states: Dictionary = {}

func _ready() -> void:
    # Ждем, пока родитель (Player) будет готов
    await owner.ready

    # Проходим по всем детям и регистрируем их
    for child in get_children():
        if child is State:
            states[child.name.to_lower()] = child
            child.transition_requested.connect(_on_child_transition)
            child.player = owner # Внедрение зависимости

    if initial_state:
        initial_state.enter()
        current_state = initial_state

func _process(delta: float) -> void:
    if current_state:
        current_state.update(delta)

func _physics_process(delta: float) -> void:
    if current_state:
        current_state.physics_update(delta)

func _on_child_transition(state: State, new_state_name: String) -> void:
    if state != current_state:
        return

    var new_state = states.get(new_state_name.to_lower())
    if not new_state:
        push_warning("State does not exist: " + new_state_name)
        return

    if current_state:
        current_state.exit()

    new_state.enter()
    current_state = new_state

Пример конкретного состояния Walk:

# Walk.gd
extends State

func enter() -> void:
    # Запускаем анимацию при входе в состояние
    player.animation_player.play("walk")

func physics_update(_delta: float) -> void:
    # Логика движения
    var input_dir = Input.get_vector("ui_left", "ui_right", "ui_up", "ui_down")

    if input_dir == Vector2.ZERO:
        transition_requested.emit(self, "Idle")
        return

    player.velocity = input_dir * 200.0
    player.move_and_slide()

Математика переходов

Часто переход между состояниями зависит от физических параметров, например, скорости. Для определения, движется ли персонаж достаточно быстро, мы используем длину вектора скорости.

Формула длины вектора (магнитуды) в 2D:

∣v⃗∣=vx2+vy2|\vec{v}| = \sqrt{v_x^2 + v_y^2}

Где ∣v⃗∣|\vec{v}| — длина вектора скорости (скалярная величина), vxv_x — компонент скорости по оси X, а vyv_y — компонент скорости по оси Y. В Godot это вычисляется методом velocity.length(). Если ∣v⃗∣≈0|\vec{v}| \approx 0, мы переходим в состояние Idle.

Предостережение

При работе с FSM и сигналами возможны критические ошибки:

  1. Состояние-Бог (God State): Не пытайтесь запихнуть всю логику в одно состояние Action, которое обрабатывает и прыжки, и стрельбу. Смысл паттерна — в дроблении логики. Если файл состояния превышает 100 строк, это сигнал к рефакторингу.
  2. Потеря ссылок при смене сцен: Если вы подписываетесь на сигналы глобального синглтона внутри локального узла, но не отписываетесь при уничтожении узла, движок может попытаться вызвать метод у удаленного объекта. В Godot 4 метод connect безопасен (связь разрывается автоматически при удалении объекта), но при использовании кастомных реализаций событий будьте внимательны.

Лабораторная работа

Задание: Расширьте созданную систему State Machine.

  1. Создайте состояние Dash (Рывок).
  2. В Dash: при входе (enter) увеличьте скорость персонажа в 3 раза на 0.2 секунды.
  3. Используйте таймер (можно через get_tree().create_timer(0.2) и await), чтобы автоматически вернуть персонажа в состояние Idle или Walk после завершения рывка.
  4. Добавьте проверку: рывок можно делать не чаще, чем раз в 1 секунду (cooldown).

В следующем модуле мы займемся физикой и векторной математикой, чтобы сделать движение нашего персонажа по-настоящему отзывчивым.

Игровая физика, математика векторов и обработка коллизий

Игровая физика, математика векторов и обработка коллизий

В предыдущих модулях мы создали архитектуру узлов и настроили машину состояний. Теперь нашему персонажу нужно взаимодействовать с миром: ходить по земле, сталкиваться со стенами и обнаруживать врагов. В Godot физика — это не просто гравитация, это сложная система симуляции и математических вычислений.

В этой лекции мы разберем разницу между игровыми циклами, научимся применять векторную алгебру для геймплейных задач и настроим профессиональную систему коллизий.

Игровой цикл: _process против _physics_process

Понимание того, когда выполнять код, критически важно для плавности игры. Godot имеет два основных цикла обновления.

1. _process(delta)

Вызывается каждый кадр отрисовки. Частота зависит от мощности компьютера и сложности сцены (FPS).

  • Использование: Интерполяция камеры, анимация UI, визуальные эффекты.
  • Проблема: Если двигать физическое тело здесь, оно будет двигаться с разной скоростью при разном FPS, а коллизии могут не сработать.

2. _physics_process(delta)

Вызывается с фиксированным интервалом (по умолчанию 60 раз в секунду). Этот интервал синхронизирован с физическим движком (PhysX в 3D или Box2D в 2D).

  • Использование: Перемещение CharacterBody, приложение сил к RigidBody, обработка RayCast.
  • Важно: delta здесь константна (обычно 0.0166 сек).

«Физику двигаем только в физическом процессе». Нарушение этого правила приведет к тому, что персонаж будет проваливаться сквозь пол при падении FPS.

Векторная математика: Язык движка

Вектор — это не просто стрелочка. Это фундаментальный инструмент для вычисления отношений между объектами. Рассмотрим две операции, необходимые каждому инженеру.

Скалярное произведение (Dot Product)

Позволяет узнать, насколько векторы сонаправлены. Это идеальный инструмент для реализации «конуса зрения» врага или проверки, находится ли объект сзади.

Формула скалярного произведения:

A⃗⋅B⃗=∣A⃗∣∣B⃗∣cos⁡θ\vec{A} \cdot \vec{B} = |\vec{A}| |\vec{B}| \cos \theta

Где A⃗\vec{A} и B⃗\vec{B} — два вектора, ∣A⃗∣|\vec{A}| и ∣B⃗∣|\vec{B}| — их длины (магнитуды), а θ\theta — угол между ними. В Godot функция a.dot(b) возвращает скалярное число.

Практическое применение: Если векторы нормализованы (длина равна 1):

  • Результат 1.0: Векторы смотрят в одну сторону.
  • Результат 0.0: Векторы перпендикулярны.
  • Результат -1.0: Векторы смотрят в разные стороны.

Векторное произведение (Cross Product)

В 3D возвращает вектор, перпендикулярный двум исходным. В 2D (в Godot) возвращает скаляр, который помогает определить, находится ли объект слева или справа от вектора направления.

Формула магнитуды векторного произведения:

∣A⃗×B⃗∣=∣A⃗∣∣B⃗∣sin⁡θ|\vec{A} \times \vec{B}| = |\vec{A}| |\vec{B}| \sin \theta

Где ∣A⃗×B⃗∣|\vec{A} \times \vec{B}| — длина результирующего вектора (или Z-компонента в 2D), ∣A⃗∣|\vec{A}| и ∣B⃗∣|\vec{B}| — длины исходных векторов, θ\theta — угол между ними.

Нормализация

Частая ошибка новичков — забывать нормализовать вектор направления. Если вы идете по диагонали, нажимая ВПРАВО и ВВЕРХ, вектор будет (1, 1). Его длина:

∣v⃗∣=12+12≈1.414|\vec{v}| = \sqrt{1^2 + 1^2} \approx 1.414

Где ∣v⃗∣|\vec{v}| — длина вектора. Это значит, что по диагонали персонаж будет двигаться на 41% быстрее. Всегда используйте .normalized(), чтобы привести длину вектора к 1.

Система Коллизий: Слои и Маски

В Godot система коллизий построена на битовых масках. Это самый эффективный способ фильтрации взаимодействий.

  • Layer (Слой): Отвечает на вопрос «КТО Я?». (Я — Игрок, Я — Стена, Я — Пуля).
  • Mask (Маска): Отвечает на вопрос «С КЕМ Я ВЗАИМОДЕЙСТВУЮ?». (Я сканирую Врагов, Я отскакиваю от Стен).

Совет: Зайдите в Project Settings -> Layer Names -> 2D Physics и дайте слоям имена (Player, World, Enemy, Projectiles). Никогда не используйте «магические числа» (Layer 1, Layer 5) в коде.

Техническое задание: Кинематический контроллер

1. Проблема

Необходимо реализовать движение персонажа с видом сверху (Top-Down), который корректно обрабатывает столкновения со стенами, не застревает на углах и всегда смотрит в сторону курсора мыши.

2. Решение (The Book of Nodes style)

Мы используем CharacterBody2D (в Godot 3 назывался KinematicBody2D).

  • Почему не RigidBody2D? Нам не нужна честная физическая симуляция (инерция, отскок), нам нужен полный контроль над перемещением.
  • Почему не Area2D? Area только обнаруживает перекрытия, но не обеспечивает физическое выталкивание из стен.

3. Реализация (The Book of Code style)

class_name PlayerController
extends CharacterBody2D

# Экспортируем параметры для настройки в инспекторе
@export_category("Movement Stats")
@export var speed: float = 300.0
@export var acceleration: float = 50.0
@export var friction: float = 20.0

func _physics_process(_delta: float) -> void:
    _handle_movement()
    _handle_rotation()

    # move_and_slide() автоматически использует velocity и delta,
    # а также обрабатывает скольжение вдоль стен.
    move_and_slide()

func _handle_movement() -> void:
    # Получаем вектор ввода (уже нормализованный движком, если использовать get_vector)
    var input_direction: Vector2 = Input.get_vector("ui_left", "ui_right", "ui_up", "ui_down")

    if input_direction != Vector2.ZERO:
        # Плавный разгон: линейная интерполяция от текущей скорости к желаемой
        velocity = velocity.move_toward(input_direction * speed, acceleration)
    else:
        # Плавная остановка (трение)
        velocity = velocity.move_toward(Vector2.ZERO, friction)

func _handle_rotation() -> void:
    # Математика: получаем глобальную позицию мыши
    var mouse_pos: Vector2 = get_global_mouse_position()

    # look_at() - встроенная функция, использующая арктангенс для поворота узла
    look_at(mouse_pos)

4. Предостережение

При работе с физикой легко допустить критические ошибки:

  1. Туннелирование (Tunneling): Если ваш снаряд летит очень быстро (например, 2000 пикселей в секунду), а FPS упал, за один кадр снаряд может пролететь сквозь тонкую стену, не вызвав события столкновения.
    • Решение: Используйте RayCast2D для проверки пути перед перемещением или включите Continuous CD (Collision Detection) в настройках тела (дорого для производительности).
  2. Изменение физики в _process: Никогда не меняйте позицию физического тела напрямую через position = ... внутри _process, если ожидаете коллизий. Это телепортация, которая ломает движок физики. Используйте только velocity и move_and_slide() внутри _physics_process.

5. Лабораторная работа

Задание: Реализуйте механику «Удар в спину» (Backstab).

  1. Создайте сцену Врага (CharacterBody2D) и Игрока.
  2. Когда Игрок атакует Врага, используйте Скалярное произведение (Dot Product), чтобы определить, находится ли Игрок за спиной Врага.
  3. Вектор направления взгляда Врага можно получить из его transform.x (если он повернут правильно).
  4. Вектор направления от Врага к Игроку: (player.global_position - enemy.global_position).normalized().
  5. Если dot_product > 0.5 (угол меньше 60 градусов сзади), нанесите двойной урон.

В следующей лекции мы отойдем от кода механик и займемся Data-Driven Design: научимся хранить параметры оружия и врагов не в коде, а в Ресурсах, создавая масштабируемую базу данных игры.

Data-Driven Design: Система ресурсов и персистентность данных

Data-Driven Design: Система ресурсов и персистентность данных

В предыдущих модулях мы научили нашего персонажа двигаться, стрелять и взаимодействовать с физическим миром. Однако, если вы посмотрите на свой код сейчас, вы, вероятно, увидите множество «магических чисел»: урон оружия прописан в скрипте пули, здоровье врага — в скрипте врага. Это путь к техническому долгу.

В этой лекции мы перейдем к Data-Driven Design (архитектуре, управляемой данными). Мы научимся отделять логику (поведение) от данных (характеристик), используя мощнейший инструмент Godot — систему Ресурсов (Resource), а также реализуем сохранение прогресса через JSON.

Философия Data-Driven: Логика vs Данные

В классическом программировании мы часто смешиваем данные и методы. В игровой разработке это приводит к проблемам масштабирования. Представьте, что у вас 100 видов мечей. Создавать 100 скриптов, наследующихся от BaseSword, — это архитектурное самоубийство.

Правильный подход:

  1. Логика (Node): Один скрипт SwordController, который умеет наносить урон, проигрывать анимацию и тратить прочность.
  2. Данные (Resource): 100 файлов ресурсов (.tres), каждый из которых хранит текстуру, значение урона и название конкретного меча.

Что такое Resource?

Resource — это базовый класс для всех типов данных в Godot. Текстуры, меши, аудиосэмплы, сцены — всё это ресурсы. Главная особенность ресурсов — они ссылочные (reference-counted) и разделяемые (shared).

Если 50 врагов используют один и тот же ресурс GoblinStats.tres, в памяти будет находиться только один экземпляр этого ресурса. Это критически важно для оптимизации памяти.

Техническое задание: База данных вооружения

1. Проблема

Нам необходимо создать систему, позволяющую геймдизайнерам добавлять новое оружие в игру, не открывая редактор кода и не прося программистов создать новый класс.

2. Решение (The Book of Nodes style)

Мы создадим кастомный ресурс WeaponData. Это не узел, он не будет находиться в дереве сцены. Это файл на диске, который мы будем «скармливать» нашему оружию.

3. Реализация (The Book of Code style)

Сначала определим структуру данных. Ключевое слово class_name обязательно, чтобы ресурс появился в контекстном меню создания файлов в редакторе Godot.

# WeaponData.gd
class_name WeaponData
extends Resource

@export_category("Visuals")
@export var name: String = "Unknown Weapon"
@export var icon: Texture2D
@export var mesh: ArrayMesh

@export_category("Combat Stats")
@export var damage: int = 10
@export var fire_rate: float = 0.5
@export var max_ammo: int = 30
@export var spread_angle: float = 5.0

# Мы можем добавить вспомогательные методы прямо в ресурс
func get_dps() -> float:
    if fire_rate <= 0:
        return 0.0
    return float(damage) / fire_rate

Теперь обновим наш скрипт оружия из первой лекции, чтобы он использовал эти данные.

# Weapon.gd
class_name Weapon
extends Node2D

# Вместо кучи переменных мы экспортируем одну ссылку на ресурс
@export var stats: WeaponData

var _current_ammo: int

func _ready() -> void:
    # Валидация данных: проверяем, назначен ли ресурс
    if not stats:
        push_error("WeaponData not assigned to " + name)
        set_physics_process(false)
        return

    _current_ammo = stats.max_ammo
    # Настройка таймера на основе данных из ресурса
    $CooldownTimer.wait_time = stats.fire_rate

func _shoot() -> void:
    # Используем данные из stats
    print("Bang! Damage: ", stats.damage)
    _current_ammo -= 1

Теперь в редакторе вы можете нажать ПКМ в файловой системе -> Create New... -> Resource -> WeaponData, настроить параметры в Инспекторе и перетащить файл в слот Stats вашего оружия.

Персистентность: Сохранение и загрузка

Ресурсы отлично подходят для статических данных (баланс игры). Но как хранить динамические данные (сохранения игрока, инвентарь)?

Существует два основных подхода:

  1. ResourceSaver/ResourceLoader: Можно сохранить весь ресурс игрока в файл .tres или .res. Это быстро, но небезопасно для передачи файлов между игроками (возможна инъекция вредоносного кода через скрипты внутри ресурсов).
  2. JSON Serialization: Стандарт индустрии. Мы вручную преобразуем объекты в словари, а словари — в текстовый формат JSON.

Реализация SaveManager

Создадим статический класс (или Autoload) для управления сохранениями.

class_name SaveManager
extends Node

const SAVE_PATH: String = "user://savegame.json"

# Структура данных для сохранения
static func save_game(player_data: Dictionary) -> void:
    # 1. Открываем файл для записи
    var file = FileAccess.open(SAVE_PATH, FileAccess.WRITE)
    if not file:
        push_error("Failed to save game: " + str(FileAccess.get_open_error()))
        return

    # 2. Преобразуем словарь в JSON-строку
    var json_string = JSON.stringify(player_data, "\t") # \t для красивого форматирования

    # 3. Пишем в файл
    file.store_string(json_string)
    print("Game saved to ", SAVE_PATH)

static func load_game() -> Dictionary:
    # 1. Проверяем существование файла
    if not FileAccess.file_exists(SAVE_PATH):
        return {} # Возвращаем пустой словарь, если сохранения нет

    # 2. Открываем файл для чтения
    var file = FileAccess.open(SAVE_PATH, FileAccess.READ)
    var content = file.get_as_text()

    # 3. Парсим JSON
    var json = JSON.new()
    var error = json.parse(content)

    if error != OK:
        push_error("JSON Parse Error: " + json.get_error_message())
        return {}

    return json.data

Пример использования в игроке

func save_player_state() -> void:
    var data = {
        "health": health,
        "position": {
            "x": global_position.x,
            "y": global_position.y
        },
        "level": current_level_name
    }
    SaveManager.save_game(data)

Предостережение

Работа с ресурсами таит в себе две критические ловушки для новичков.

1. Проблема разделяемого состояния (Shared State)

По умолчанию ресурсы кэшируются. Если вы измените свойство ресурса во время игры, оно изменится для всех объектов, использующих этот ресурс.

Пример катастрофы: У вас есть ресурс EnemyStats с hp = 100. Вы назначаете его всем гоблинам. Когда вы бьете одного гоблина и в коде делаете stats.hp -= 10, здоровье уменьшается у всех гоблинов на уровне. Более того, если вы запустите игру в редакторе, значение hp в файле ресурса может измениться навсегда.

Решение: Никогда не меняйте данные внутри Resource во время игры, если это не уникальный экземпляр. Храните текущее здоровье (current_hp) в скрипте узла (Node), а максимальное (max_hp) берите из ресурса.

2. Циклические зависимости ресурсов

Если ResourceA имеет переменную типа ResourceB, а ResourceB ссылается обратно на ResourceA, Godot может не загрузить их корректно или вызвать переполнение стека при инициализации.

Решение: Используйте слабые ссылки или пересмотрите архитектуру. Ресурсы должны образовывать древовидную структуру, а не сетевую.

Лабораторная работа

Задание: Создайте систему инвентаря на основе ресурсов.

  1. Создайте ресурс ItemData с полями: id (String), display_name (String), weight (float).
  2. Создайте ресурс Inventory, который содержит экспортируемый массив Array[ItemData].
  3. Реализуйте функцию calculate_total_weight() внутри ресурса Inventory, которая возвращает сумму весов всех предметов.
  4. Напишите скрипт, который сохраняет список ID предметов инвентаря в JSON и умеет восстанавливать инвентарь, загружая соответствующие ресурсы по их ID (используйте load("res://items/" + id + ".tres")).

В следующей лекции мы займемся Интерфейсами и UX, чтобы визуализировать наши данные: создадим адаптивный инвентарь и полоски здоровья, реагирующие на изменение данных.

Проектирование интерфейсов, жизненный цикл объектов и оптимизация

Проектирование интерфейсов, жизненный цикл объектов и оптимизация

Мы прошли долгий путь: от понимания узлов и сигналов до физики и баз данных на ресурсах. Теперь у нас есть работающая логика и данные, но нет способа отобразить их игроку и обеспечить стабильную производительность при большом количестве объектов.

В этой, завершающей основной блок, лекции мы разберем три критически важных аспекта профессиональной разработки: создание адаптивных интерфейсов (UI), управление жизненным циклом сущностей и паттерны оптимизации памяти.

Архитектура UI: Контейнеры против Якорей

Разработка интерфейсов в Godot строится на узлах Control. Главная ошибка новичков — попытка расставлять кнопки и панели вручную, используя координаты пикселей. Это работает на вашем мониторе, но ломается на любом другом разрешении экрана.

Система Контейнеров

Godot предлагает мощную систему автоматической верстки. Вместо того чтобы задавать позицию (position), вы должны задавать правила поведения.

Основные узлы верстки:

  • HBoxContainer / VBoxContainer: Выстраивают дочерние элементы в горизонтальный или вертикальный ряд. Игнорируют ручную позицию детей.
  • GridContainer: Выстраивает элементы в сетку с заданным количеством колонок.
  • MarginContainer: Добавляет отступы от краев для своего содержимого.
  • PanelContainer: Автоматически подстраивает фон (Panel) под размер содержимого.

Size Flags: Expand и Fill

Чтобы элементы внутри контейнеров вели себя предсказуемо, необходимо понимать настройки Size Flags:

  1. Fill (Заполнение): Элемент займет все доступное ему пространство по выбранной оси.
  2. Expand (Расширение): Элемент будет «расталкивать» соседей, требуя больше места, если оно есть в родительском контейнере.

Ресурсы темы (Theme)

Никогда не настраивайте шрифты и цвета в инспекторе для каждой кнопки отдельно. Используйте ресурс Theme. Это CSS для Godot. Создав один файл .tres с темой и назначив его корневому узлу UI, вы автоматически примените стиль ко всем дочерним элементам. Это позволяет менять визуальный стиль всей игры за секунду.

Жизненный цикл: Рождение и Смерть объектов

В динамической игре объекты постоянно появляются (пули, враги, эффекты) и исчезают. Неправильное управление этим процессом ведет к утечкам памяти и падению FPS.

Инстанцирование (Instantiate)

Сцены в Godot хранятся на диске как PackedScene. Чтобы добавить сцену в игру, нужно создать её экземпляр.

# Загрузка ресурса сцены (лучше делать через @export)
var bullet_scene: PackedScene = preload("res://bullet.tscn")

func spawn_bullet() -> void:
    # 1. Создание экземпляра в памяти (еще не в дереве сцены)
    var bullet_instance: Node = bullet_scene.instantiate()

    # 2. Настройка параметров
    bullet_instance.position = global_position

    # 3. Добавление в дерево сцены
    # Важно: добавляем не к себе, а в корень уровня, чтобы пуля летела независимо
    get_tree().current_scene.add_child(bullet_instance)

Удаление (Queue Free)

В Godot есть два метода удаления узлов:

  1. free(): Удаляет объект немедленно. Опасно! Если в этот момент движок обрабатывает физику этого объекта или другой скрипт пытается к нему обратиться, игра вылетит (crash).
  2. queue_free(): Помечает объект на удаление. Движок безопасно удалит его в конце текущего кадра, когда закончит все вычисления. Используйте этот метод в 99% случаев.

Оптимизация: Математика производительности

Производительность игры измеряется не только в кадрах в секунду (FPS), но и во времени кадра (Frame Time).

Формула зависимости времени кадра от FPS:

T=1000FPST = \frac{1000}{FPS}

Где TT — время, доступное для отрисовки одного кадра в миллисекундах, 10001000 — количество миллисекунд в секунде, а FPSFPS — целевое количество кадров в секунду.

Для стабильных 60 FPS у нас есть всего:

T60=100060≈16.66T_{60} = \frac{1000}{60} \approx 16.66

Где T60T_{60} — бюджет времени на один кадр в миллисекундах (примерно 16.66 мс). Если ваши скрипты, физика и рендеринг суммарно занимают 17 мс, игра начнет тормозить.

Операции instantiate() и queue_free() являются «дорогими». Они требуют выделения памяти, парсинга сцены и перестройки дерева. Если вы создаете и удаляете 100 пуль в секунду, процессор будет перегружен.

Паттерн Object Pooling (Пул объектов)

Решение проблемы частых созданий/удалений — переиспользование.

Вместо queue_free() мы скрываем объект и отключаем его обработку. Вместо instantiate() мы берем уже готовый скрытый объект из массива.

Техническое задание: Динамический инвентарь

1. Проблема

Необходимо отобразить содержимое инвентаря игрока (массив ресурсов из прошлой лекции) в виде сетки иконок. Интерфейс должен обновляться при изменении данных и корректно отображаться на разных экранах.

2. Решение (The Book of Nodes style)

Мы будем использовать:

  • GridContainer: Для автоматического размещения слотов в сетку.
  • PanelContainer: Для фона каждого слота.
  • TextureRect: Для отображения иконки предмета.
  • Resource (Inventory): Как источник данных.

3. Реализация (The Book of Code style)

Создадим сцену слота InventorySlot.tscn и скрипт менеджера UI.

# InventoryUI.gd
class_name InventoryUI
extends Control

# Ссылка на ресурс инвентаря (Data-Driven)
@export var inventory_data: Inventory
# Сцена одного слота для инстанцирования
@export var slot_scene: PackedScene

@onready var grid_container: GridContainer = $MarginContainer/GridContainer

func _ready() -> void:
    # Подписываемся на изменение данных, если такой сигнал есть в ресурсе
    if inventory_data:
        inventory_data.changed.connect(refresh_ui)
        refresh_ui()

func refresh_ui() -> void:
    # Очистка текущего UI (простой способ, без пулинга)
    for child in grid_container.get_children():
        child.queue_free()

    # Генерация новых слотов
    for item: ItemData in inventory_data.items:
        var slot = slot_scene.instantiate()
        grid_container.add_child(slot)

        # Предполагаем, что у слота есть метод set_item
        if slot.has_method("set_item"):
            slot.set_item(item)

Код самого слота (InventorySlot.gd):

extends PanelContainer

@onready var icon_texture: TextureRect = $MarginContainer/Icon

func set_item(item: ItemData) -> void:
    if item and item.icon:
        icon_texture.texture = item.icon
        tooltip_text = item.display_name
    else:
        icon_texture.texture = null

4. Предостережение

При работе с UI и жизненным циклом возможны критические ошибки:

  1. Блокировка ввода (Input Blocking): По умолчанию узлы Control (особенно Panel и Button) поглощают события мыши. Если вы разместите прозрачную панель на весь экран поверх игры, вы не сможете кликать по игровым объектам.
    • Решение: Установите свойство Mouse -> Filter в Ignore или Pass для элементов, которые не должны перехватывать клики.
  2. Утечка памяти через Callable: Если вы подписываете UI на сигналы глобального ресурса (как в примере выше inventory_data.changed.connect), но не отписываетесь при уничтожении UI, ресурс будет хранить ссылку на удаленный узел. В Godot 4 это обрабатывается лучше, но хорошим тоном считается явная отписка или использование флага CONNECT_ONE_SHOT для одноразовых событий.

5. Лабораторная работа

Задание: Реализуйте простой Object Pool для пуль.

  1. Создайте класс BulletPool (наследуется от Node).
  2. При старте (_ready) создайте 20 экземпляров пули, добавьте их в сцену, деактивируйте (visible = false, set_process(false)) и добавьте в массив available_bullets.
  3. Реализуйте метод get_bullet(pos: Vector2, dir: Vector2), который берет пулю из массива, активирует её и выставляет позицию.
  4. В скрипте пули замените queue_free() на вызов метода пула return_bullet(self), который скрывает пулю и возвращает её в массив.

Это завершает наш базовый курс. Вы научились создавать архитектуру, управлять данными, физикой и интерфейсом. Теперь вы готовы создавать полноценные системы, а не просто наборы скриптов.