translatedTitles.Article
DG
Dmitriy Gorin
11/26/25

Godot. Урок 4. Разнообразие оружия. Простейший HUD

С вами

Дмитрий Горин, ментор по Godot

  • Разработчик графических подсистем в АТОМ и инди-разработчик игр
  • Участвую экспертом в мероприятиях и хакатонах, посвященных разработке игр
  • Веду образовательную деятельность при университете РГРТУ им. В.Ф. Уткина (группа ВК)


Вопросы по движку и программе можно задавать в соответствующем топике Godot чата Практик•ON



Предисловие

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


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


Доработки классов

Для новых типов оружия потребуется описать свойства, определяющие:

  • режим стрельбы;
  • размер очереди;
  • количество поражающих элементов;
  • разброс поражающих элементов.

В коде “range_weapon.gd” описал это следующим образом:

Python# Типы режимов стрельбы.
enum FiringMode {
 Single,
 Auto,
 Line,
}
…
# Режим стрельбы.
var fire_mode: FiringMode
# Длина очереди (если доступен режим).
var line_count: int
# Радиус разброса при стрельбе (пусть фиксированный) в градусах.
var spread_of_fire: float
# Установленное (патронами) количество поражающих элементов.
var pellet_count: int = 1 : set = set_pellet_count
# Разброс поражающих элементов.
var spread_of_pellet: float = 0.0 : set = set_pellet_spread


Часть из этих параметров уже можно использовать в существующем пистолете:

Pythonfunc _init() -> void:
 fire_mode = FiringMode.Single
 fire_delta = 0.25
 spread_of_fire = 7.0
 …


func shoot() -> void:
	…
	if ammo_count > 0:
  …
	ammo_count -= 1
  # Разброс.
  shoot_cast.rotation_degrees.x = randf_range(-spread_of_fire, spread_of_fire)
  shoot_cast.rotation_degrees.y = randf_range(-spread_of_fire, spread_of_fire)
	…


func set_pellet_count(value: int) -> void:
 pellet_count = value


func set_pellet_spread(value: float) -> void:
 spread_of_pellet = value

Пояснения к коду:

  • Мы предполагаем, что стандартное поведение оружия – это одна пуля выпускаемая из оружия, поэтому у пистолета настраивается только тип стрельбы и разброс при стрельбе.
  • Перед выстрелом случайным образом отклоняем в радиусе отклонения узел RayCast3D, чтобы выстрел проверялся не ровно в точке посередине, а где-то около неё. Соответственно, поставлю её перед выстрелом.


Не забудем добавить параметров в класс Ammo:

Python# Тип патронов.
var type: Type
# Установленное (патронами) количество поражающих элементов.
var pellet_count: int = 1
# Разброс поражающих элементов.
var spread_of_pellet: float = 0.0

Обратите внимание, что я переименовал тип патронов с Ammo.ammo_type → Ammo.type, чтобы не было дублирования имени, и так понятно, что у патронов тип – это тип патронов. Не забудьте поправить все моменты использования Ammo.type.


Перед тем как продолжим, есть момент, который я хочу поправить из предыдущей части руководств – это небольшой рефакторинг (переделывание уже существующего и работающего функционала). Рефакторинг заключается в том, что персонаж Character самостоятельно берет свойства из патронов, и передает их в оружие. Но зачем игроку разбираться в том, какие свойства есть у пистолета и патронов к нему?


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


В RangeWeapon перепишем метод загрузки боеприпасов в оружие:

Pythonfunc upload_ammo(ammo: Ammo, requested_count: int = -1) -> void:
 if not ammo.type == ammo_type:
  return
 # Если запросили полный.
 if requested_count < 0:
  requested_count = get_magazine_size() - ammo_count
 
 var taken_ammo: int = clamp(ammo.count, 0, requested_count)
 ammo_count += taken_ammo
 ammo.count -= taken_ammo
 
 damage = ammo.damage
 pellet_count = ammo.pellet_count
 spread_of_pellet = ammo.spread_of_pellet
, а у Character пропишем в методе лишь одну строчку:
func _on_weapon_reloaded(type: Ammo.Type, requested_count: int) -> void:
 gun.upload_ammo(ammo[type], requested_count)


Логика проста и была перенесена из персонажа в оружие, поэтому оставляем это без комментариев, и переходим к созданию…


Автомат

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

Pythonclass_name RangeAutoRifle
extends RangeWeapon
…


func _init() -> void:
 fire_mode = FiringMode.Auto
 fire_delta = 0.1
 spread_of_fire = 4.0
 ammo_type = Ammo.Type.Rifle
 
 if start_ammo > -1:
  ammo_count = clamp(start_ammo, 0, get_magazine_size())


static func get_magazine_size() -> int:
 return 30

, в котором мы лишь поправили режим. Можно и другие характеристики поправить, а чего бы и нет?


Рис. 1. Подсказка к необходимым действиям.


Это всё по причине того, что в классе RangeWeapon уже заложена проверка на возможность следующего выстрела, просто нужно дополнить, что если оружие автоматическое, мы сбрасываем флаг in_fire:

Pythonfunc _process(delta: float) -> void:
 _remaining_time -= delta
 if is_triggered and _remaining_time <= 0.0:
  shoot()
  _remaining_time = fire_delta
  if fire_mode == FiringMode.Auto and ammo_count > 0:
   in_fire = false

Ой, как удобно!


Можно копировать и вставить саму модель пистолета с её анимациями, но я решил чуть заморочиться, и получилось как на рисунке 2.


Рис. 2. “Смоделированный” автомат.


Конечно же, доработка модели потребовала изменений в анимации, но чисто поменял положения составных частей.

Хотелось бы сказать, что пошли проверять, но кое-что забыли… А патроны кто будет выдавать?


Создадим как и для пистолетных патронов “ящик”, сделав модель с другого цвета материалом (рис. 3). Код примерно тот же, что и пистолетный, но с другими подставленными значениями:

Pythonclass_name RifleAmmo
extends Ammo
const DEFAULT_DAMAGE: float = 10
@onready var mesh_instance: MeshInstance3D = get_node_or_null("MeshInstance3D")


func _init(base_count: int = -1, base_max_count: int = -1) -> void:
 if not base_max_count < 1:
  max_count = base_max_count
 if not base_count < 0:
  count = base_count
 # Проверим, что не вышли за предел максимального количества.
 count = clamp(count, 0, max_count)
 
 type = Type.Rifle
 damage = DEFAULT_DAMAGE


func _ready() -> void:
 if count > 0:
  interactable = true
 hintable = true


func set_interactable_state(state: bool) -> void:
 if is_instance_valid(mesh_instance):
  mesh_instance.mesh.material.albedo_color = Color.WHITE if state == false else Color("599437")
  interactable = state


Рис. 3. Ящик автоматных боеприпасов.


А ещё сделаем карман для этих патронов персонажу:

Pythonvar ammo: Dictionary[Ammo.Type, Ammo] = {
 Ammo.Type.Pistol: PistolAmmo.new(25, 100),
 Ammo.Type.Rifle: RifleAmmo.new(30, 60),
}

И добавим ящик на уровень (рис. 4).


Рис. 4. Добавленный ящик на уровень.


Время выдать автомат и протестировать его!


Рис. 5. Добавленный автомат на сцену игрока.


Для упрощения автомат будет по соседству с пистолетом на сцене (рис. 5), просто скроем одно и покажем другое. Не забудьте присоединить сигнал о заряжании оружия на игрока _on_weapon_reloaded(type, requested_count). И раз изменилось активное оружие, подправим ссылку на это оружие у Character:

Python@onready var gun: RangeWeapon = $Head/AutoRifle

Вот теперь запускаем сцену.


Аним 1. Тестирование автомата.


Рис. 6. Автомат – сила!


Дробовик

Дробовик также стреляет по одному разу, как и пистолет, просто, но… заряжается по одному патрону, если у него не магазин. Так и поражение у нас по площади!


Так, не паникуем. Повторяем Ctrl + C, Ctrl + V, как и с автоматом, то есть пилим код для дробовика, модель, боеприпасы, и начинаем разбираться, как это всё дело сделать хорошо. Плохо не надо делать.


В конструкторе устанавливаем параметры как нам надо:

Pythonclass_name RangeShotgun
extends RangeWeapon
@onready var animation_player: AnimationPlayer = $AnimationPlayer
@onready var shoot_cast: RayCast3D = $RayCast3D
func _init() -> void:
  fire_mode = FiringMode.Auto
  fire_delta = 1.0
  spread_of_fire = 4.0
  ammo_type = Ammo.Type.Shotgun
  
  if start_ammo > -1:
    ammo_count = clamp(start_ammo, 0, get_magazine_size())


static func get_magazine_size() -> int:
  return 6


В самом начале мы добавили в оружие параметры для количества поражающих элементов и их разброса pellet_count и spread_of_pellet соответственно как раз для дробовика. Моя идея в том, что каждый осколок будет считаться за отдельный снаряд, а значит, что каждый из них потребует свой RayCast3D узел.

Python# Настройка RayCast3D в зависимости от количества поражающих элементов.
func set_pellet_count(value: int) -> void:
  if pellet_count == value:
    return
  pellet_count = value
  if shoot_cast.get_child_count() > 0:
    for child in shoot_cast.get_children():
      child.queue_free()
  if value == 1:
    return
  
  var pellet_raycat: RayCast3D = shoot_cast.duplicate()
  for i in range(value):
    var pellet_copy: RayCast3D = pellet_raycat.duplicate()
    shoot_cast.add_child(pellet_copy)


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


При стрельбе это будет учитываться следующим способом:

Pythonfunc shoot() -> void:
 if in_load or in_fire:
  return
 if ammo_count > 0:
  in_fire = true
  animation_player.stop()
  animation_player.play("shoot")
  ammo_count -= 1
  # Разброс.
  shoot_cast.rotation_degrees.x = randf_range(-spread_of_fire, spread_of_fire)
  shoot_cast.rotation_degrees.y = randf_range(-spread_of_fire, spread_of_fire)
  # Разброс и попадание каждой дроби.
  if shoot_cast.get_child_count() > 0:
   for pellet in shoot_cast.get_children():
    pellet.rotation_degrees.x = randf_range(-spread_of_pellet, spread_of_pellet)
    pellet.rotation_degrees.y = randf_range(-spread_of_pellet, spread_of_pellet)
    pellet.force_raycast_update()
    if pellet.is_colliding():
     var collider = pellet.get_collider()
     print("На глаза попался ", collider, ". Пиу!")
     if collider.has_method("change_health"):
      collider.change_health(damage)
  else:
   # Выстрел по сущности.
   shoot_cast.force_raycast_update()
   if shoot_cast.is_colliding():
    var collider = shoot_cast.get_collider()
    print("Попадание по ", collider)
    if collider.has_method("change_health"):
     collider.change_health(damage)
  #
  print("Патронов осталось ", ammo_count, "/", get_magazine_size())
  return
 else:
  in_fire = true
  animation_player.play("dryfire")
  return


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


А что же с загрузкой боеприпасов?

Python# У этого дробовика зарядка по одному.
func on_reloaded() -> void:
 emit_signal("reloaded", ammo_type, 1)


# Иное поведение при получении патронов.
func upload_ammo(ammo: Ammo, requested_count: int = -1) -> void:
 if not ammo.type == ammo_type:
  return
 
 var with_animation: bool = true
 
 if requested_count < 0:
  # Если потребуется зарядить полную обоиму, значит, не нужна анимация.
  # Запас для инструментов инвентаря..?
  with_animation = false
  requested_count = get_magazine_size() - ammo_count
 
 var taken_ammo: int = clamp(ammo.count, 0, requested_count)
 
 damage = ammo.damage
 pellet_count = ammo.pellet_count
 spread_of_pellet = ammo.spread_of_pellet
 
 ammo_count += taken_ammo
 ammo.count -= taken_ammo
 
 if not (ammo.count == 0 or is_triggered or is_full()) and with_animation:
  animation_player.seek(0.1, true)


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


А значит, что есть и особенности в анимации перезарядки? Нет, вполне себе стандартно (рис. 7).


Рис. 7. Анимация “reload” дробовика.


У боеприпасов для дробовика чуть поменяется конструктор:

Pythonclass_name ShotgunAmmo
extends Ammo
const DEFAULT_DAMAGE: float = 3
const DEFAULT_PELLET_COUNT: int = 9
const DEFAULT_PELLED_SPREAD: float = 0.6
@onready var mesh_instance: MeshInstance3D = get_node_or_null("MeshInstance3D")


func _init(base_count: int = -1, base_max_count: int = -1) -> void:
 if not base_max_count < 1:
  max_count = base_max_count
 if not base_count < 0:
  count = base_count
 # Проверим, что не вышли за предел максимального количества.
 count = clamp(count, 0, max_count)
 
 type = Type.Shotgun
 damage = DEFAULT_DAMAGE
 pellet_count = DEFAULT_PELLET_COUNT
 spread_of_pellet = DEFAULT_PELLED_SPREAD
…

Чисто добавили констант для данной дроби и установили в соответствующие параметры. А, и цвет в set_interactable_state(state) не забудьте другой поставить.


Даем патроны и дробовик персонажу, по аналогии с автоматом, и проверяем.


Аним. 2. Тестирование дробовика.


Рис. 8. Это моя бум-палка!


Переключение оружия

У нас набралось достаточно оружия, а может будет и больше, которым мы бы хотели владеть в игре и переключать. Значит, нам нужно сделать слоты, по которым мы будем переключаться нажатием, например, цифры от 1 до номера оружия.


Сделаем у персонажа изменения в древе (рис. 9) и в коде добавим массив снаряжения, и всё для работы с ним:

Python# Выбранный слот оружия.
var current_gun: int = 0
@onready var weapon_node: Node3D = $Head/WeaponPosition
# Пушки. Внимание, их расположение по слотам!
@onready var guns: Array[Weapon] = [
 $Head/WeaponPosition/PistolGun,
 $Head/WeaponPosition/Shotgun,
 $Head/WeaponPosition/AutoRifle,
]
…


func _ready() -> void:
 Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)
 select_gun(0)
…


# Переключает оружие на располагаемое в slot.
func select_gun(slot: int) -> void:
 current_gun = slot
 for gun_slot in guns.size():
  guns[gun_slot].visible = (current_gun == gun_slot)

Во всех местах, где есть gun, меняем на guns[current_gun].


Рис. 9. Перемещение оружия под упорядочивающий узел.


Добавим соответствующие действия (рис. 10) и опишем в контроллере персонажа, что по ним происходит:

Python if event.is_action_pressed("select_slot_1"):
  character.select_gun(0)
 elif event.is_action_pressed("select_slot_2"):
  character.select_gun(1)
 elif event.is_action_pressed("select_slot_3"):
  character.select_gun(2)


Рис. 10. Действия переключения оружия.


Теперь в запущенном проекте есть возможность переключать активное оружие на лету.


Графические интерфейсы

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


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


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


Базовый Control определяет положение и привязки (якори) для отображения на экранном пространстве. На нем базируются все остальные UI элементы:

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


Создайте папку “ui → hud” и в ней сцену “hud.tscn”. Но тип корня будет не “Пользовательский интерфейс”, а в последнем пункте напишите “CanvasLayer” (рис. 11).

Рис. 11. Корневой узел для HUD.

Почему так? Потому что наш интерфейс будет представлять из себя кучу слоев отображения графической информации, и HUD – один из них.


Уже к нему добавьте базовый узел Control, а к нему TextureRect, которые я быстренько переименую под нужный функционал, как на рисунке 12.


Рис. 12. Начало HUD сцены.


Настройте корневой Control как на рисунке 13. Выставленный Anchor Preset позволит позиционировать HUD на всем пространстве экрана, а Mouse → Filter для того, чтобы интерфейс не перехватывал действия мыши.


Рис. 13. Настройки корневого узла для HUD.


А в Aim я положу созданную самостоятельно иконку перекрестия и поставлю по центру (относительно родительского элемента), как на рисунке 14.


Рис. 14. Настройки для перекрестия.


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


Рис. 15. Панель оружия и боеприпасов.


HBoxContainer выставлен в левый нижний угол, и автоматически подставляется под размер внутренних элементов.


Внутри Panel, каждая под своё оружие (и с соответствующим названием в дереве). Панель у меня используется чисто потому, что я ею подсвечиваю выделенное в текущий момент, меняя её цвет. .Ей я выставил лишь Custom Minimum Size на (100, 100).


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


Texture – это TextureRect, которая содержит иконку оружия. Тут с настройками заморочнее (рис. 16). Всё это сделано, чтобы текстура 64*64 была растянута на всю ширину панели.


Рис. 16. Настройки иконки оружия.


Ammo – текстовая метка Label, которая будет отображать количество боеприпасов в карманах. Там установлен режим якоря в “внизу по центру”.


Повторите для всех представленных у вас оружий.


Если вы хотите узнать, как я сделал такие иконки? Взял скриншот из вьюпорта и почистил от лишнего в графическом редакторе (воспользуйтесь Krita, например).


С кодом всё просто. Делаете функции, которые будут вызываться для заполнения, и распихиваете информацию по узлам:

Pythonclass_name HUD
extends CanvasLayer
const SELECTED_SLOT_COLOR: Color = Color("ff000080")
const DEFAULT_SLOT_COLOR: Color = Color("ffffff00")
@onready var guns_slots: Array[Panel] = [
 $Control/HBoxContainer/PistolSlot,
 $Control/HBoxContainer/ShotgunSlot,
 $Control/HBoxContainer/RifleSlot,
]
@onready var ammo_slots: Array[Label] = [
 $Control/HBoxContainer/PistolSlot/Ammo,
 $Control/HBoxContainer/ShotgunSlot/Ammo,
 $Control/HBoxContainer/RifleSlot/Ammo,
]


func select_gun(slot: int) -> void:
 for gun_slot in guns_slots.size():
  guns_slots[gun_slot].self_modulate = SELECTED_SLOT_COLOR if slot == gun_slot else DEFAULT_SLOT_COLOR


func set_ammo(ammo_pack: Ammo) -> void:
 if not is_instance_valid(ammo_pack):
  return
 var text: String = "%d/%d" % [ammo_pack.count, ammo_pack.max_count]
 if ammo_pack is PistolAmmo:
  ammo_slots[0].text = text
 if ammo_pack is ShotgunAmmo:
  ammo_slots[1].text = text
 if ammo_pack is RifleAmmo:
  ammo_slots[2].text = text

Не забудьте в массиве guns_slots сохранить очередность оружия, как везде.


Тут используется условие в строчку, называется тернарная операция. Подставляются разные данные, в зависимости от описанного в if условия.


Эта сцена должна где-то находиться. Можно приложить её к персонажу, или вроде того… Но HUD у нас будет один, почему бы нам не сделать из него Singletone? Singleton (с англ. «одиночка») — это паттерн проектирования, гарантирующий, что у класса будет только один экземпляр. Таким образом, в Godot синглтон будет доступен всему проекту по имени. Чтобы это сделать, перейдите в настройки проекта и добавьте эту сцену с именем, к которым отовсюду хотите получить к ней доступ (рис. 17).


Рис. 17. Создание синглтона.


Теперь у персонажа, в местах где данные нужно обновлять, просто допишем следующее:

Pythonfunc _ready() -> void:
 Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)
 select_gun(0)
 for type in ammo:
  hud.set_ammo(ammo[type])


func select_gun(slot: int) -> void:
 current_gun = slot
 for gun_slot in guns.size():
  guns[gun_slot].visible = (current_gun == gun_slot)
 hud.select_gun(slot)


func interact() -> void:
 var object = interact_cast.get_collider()
 if object is Item:
  if not object.interactable:
   return
  if object is Ammo:
   var delta: int = ammo[object.type].max_count - ammo[object.type].count
   var received: int = object.get_ammo(delta)
   ammo[object.type].count += received
   _update_ammo_display()


func _on_weapon_reloaded(type: Ammo.Type, requested_count: int) -> void:
 guns[current_gun].upload_ammo(ammo[type], requested_count)
 _update_ammo_display()


# Для обновления информации о патронах на HUD интерфейсе.
func _update_ammo_display() -> void:
 for type in ammo:
  hud.set_ammo(ammo[type])


Если это запустить, оно должно работать.


Аним. 3. Проверка работы HUD и переключения оружия.


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


А для удобства оставляю здесь архив с проектом, с которого писалось руководство.

hub.comments