translatedTitles.Article
DG
Dmitriy Gorin
11/24/25

Godot. Урок 3. Предметы, оружие, и взаимодействие персонажа с ними

С вами

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

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


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


Предисловие

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


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


Здесь будет много кода и рассуждений, и мало картинок. Сорян.


Постановка задания

Перед тем, как что-то делать, нужно определиться, а как хочется, чтобы работало? Предметы подбираются автоматически, или игрок должен сам инициировать их использование? И они будут использоваться сразу, или же можно их подбирать, копить? И всё ли так можно откладывать? И как это использовать, если откладывать? Это же систему инвентаря нужно… И так вопрос за вопросом. И каждый аспект очень сильно может повлиять на ощущение и динамику игры!

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


Рис. 1. Пример плана на день из интернета.


В моем случае получается не золотая, но точно середина:

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

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


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


Рис. 2. Упрощенная схема взаимодействия.

Приступим…


Базовый предмет

Для описания любого предмета, который встретится в игре как в мире, так и снаряженным каким-то персонажем, мы говорим, что это “предмет в своей основе”. Так же и с базовым классом.


Мы уже описали пустой базовый класс в файле “item.gd”, время его наполнить:

Pythonclass_name Item
extends Node
# Уникальный идентификатор предмета.
@export var id: int
# Строковое имя игрового предмета, не связанное с именем узлов и сцен.
@export var string_name: String : get = get_string_name
# Описание, всплывающее при просмотре на предмет.
@export var description: String
# Флаг определяет возможность взаимодействия с объектом.
var interactable: bool = false : set = set_interactable_state, get = is_interactable
# Флаг определяет необходимость вывода описания предмета.
var hintable: bool = false : set = set_hintable_state, get = is_hintable


func get_string_name() -> String:
 return string_name


func set_interactable_state(state: bool) -> void:
 interactable = state


func is_interactable() -> bool:
 return interactable


func set_hintable_state(state: bool) -> void:
 hintable = state


func is_hintable() -> bool:
 return hintable

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

  • С помощью ключевого слова @export возможно не только устанавливать значения переменных через инспектор. Такие переменные можно устанавливать через инспектор для каждого экземпляра (копии) этого объекта и подклассов, которые мы будем описывать позже. Есть и другие способы это переопределять, но тут остановимся на таком варианте.
  • У имени предмета, которое будем отображать, сделаем get-метод, чтобы можно было дополнять имя подписями вроде *пусто*.
  • У предмета id будет незаменим, если вам потребуется проверить, что вы работаете не просто с каким-то классом предметов, а с каким-то уникальным, определенным, и, возможно, единственным в своем роде, предметом.


На всякий случай проговорим про set-get методы (или геттеры и сеттеры) — это методы, которые используются для доступа и изменения значений приватных переменных класса. Так как в Godot все переменные GDScript доступны (публичны), эти методы мною часто используются для установки дополнительной логики при установлении нового значения переменной или возвращении текущего.


Патроны

Как бы то ни было странно, но далее мы переходим к патронам, а не оружию. Причина проста – не патроны зависят от оружия, а оружие от патронов. Да и описать их будет сильно проще. Создадим папку “ammo” в папке “items”, создадим скрипт “ammo.gd”, и наполним его следующим кодом:

Pythonclass_name Ammo
extends Item
# Типы патронов.
enum Type {
  Pistol,
  Rifle,
  Shotgun
}
# Количество в коробке.
@export var count: int
# Максимальный запас в коробке.
@export var max_count: int
# Урон пули.
@export var damage: float
# Тип патронов.
var ammo_type: Type


func get_string_name() -> String:
  return string_name + "\n(%d)" % count


func get_ammo(requested_count: int) -> int:
  if requested_count >= count:
    hintable = false
    interactable = false
    var temp: int = count
    count = 0
    return temp
  else:
    count -= requested_count
    return requested_count

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

  • Видим новую конструкцию – enum. Type – наш собственный тип, который можно представить как набор пар <ключ, значение>, где ключ – некоторое имя, а значение – целочисленное значение, подставляемое вместо имени.
  • Определенный в базовом классе метод get_string_name() переопределим, добавив к описанию количество имеющихся боеприпасов.
  • Возложим на класс боеприпасов метод получения. Суть его в том, что функция вернет то количество боеприпасов из запрошенных, которое в наличии.


Прелесть этого класса будет в том, что его описанные свойства подойдут как для боеприпаса, который хранится у персонажа и будет использоваться в оружии, так и подойдет для ящика патронов, который будет стоять на карте (а то и для работы в инвентаре). Я рассматриваю сущность Ammo не как боеприпас в единичном экземпляре (хоть это и возможно), а как их “ящик”. Поэтому тут есть параметры count и max_count.


Создадим папку “objects/items/ammo/pistol/” и в ней опишем пистолетные боеприпасы. Создаем сцену для представления объекта в игровом мире в файле “pistol.tscn”, как на рисунке 3. Если вы как и я решили, что ящик не должен иметь физическое тело, т.е. нельзя по нему прыгать и ходить, используйте вместо StaticBody3D узел Area3D.


Рис. 3. Сцена с ящиком патронов.


В файле “pistol_ammo.gd” (потому что класс будет назван PistolAmmo) следующее:

Pythonclass_name PistolAmmo
extends Ammo
const DEFAULT_DAMAGE: float = 5
@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_count < 0:
    count = base_count
  if not base_max_count < 1:
    max_count = base_max_count
  # Проверим, что не вышли за предел максимального количества.
  count = clamp(count, 0, max_count)
  
  ammo_type = Ammo.Type.Pistol
  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("a12d2f")
  interactable = state

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

  • Переменная mesh_instance нужна для управления визуалом коробки. Так как пистолетные патроны не всегда будут иметь меш этого ящика, например “в кармане” персонажа. И как вы видите, можно присвоить не только заранее установленное значение этой переменной, но и результат метода. get_node_or_null(path) позволит не получить ошибку при создании боеприпасов без 3D модели.
  • _init() – это встроенный метод, который вызывается в первые моменты жизни объекта на игровой сцене ли, или просто PistolAmmo.new() в коде. Переопределяемыми при создании сделаны максимальный размер контейнера, и текущее количество боеприпасов в нем. -1 – явно неверное значение, выставленное по умолчанию. Если не передать эти 2 параметра, то значения возьмутся из тех, что вы укажете в инспекторе для данного ящика патронов (рис 4).
  • _ready() обновит вид ящика патронов, в зависимости от его наполнения, так как тут же мы переопределим set_interactable_state(state). Напомню, что этот метод был указан как сеттер в базовом классе Item.
  • Установить mesh_instance.mesh.material.albedo_color можно в том случае, если вы не забыли создать материал для вашего меша ящика патронов.


Рис. 4. Выставленные параметры для этого контейнера патронов.


Подбор патронов

Ящик патронов готов, а персонаж к такому ещё не готов. Исправляем. Добавляем новое действие “interact”, обрабатываем его в “user_input_controller.gd”, и добавим в “character.gd” карманы для патронов, новый RayCast3D для взаимодействия с предметами (включите “Collide With → Areas” в свойствах RayCast3D, если у вас будут предметы Area3D) и метод interact() для описания взаимодействия. С кодом последнего помогу:

Python# Запасы боеприпасов.
var ammo: Dictionary[Ammo.Type, Ammo] = {
  Ammo.Type.Pistol: PistolAmmo.new(25, 100),
}
…

# Raycast для взаимодействия персонажа с окружением.
@onready var interact_cast: RayCast3D = $Head/InteractCast# Описание взаимодействия на каждый из типов встречаемых предметов.
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.ammo_type].max_count - ammo[object.ammo_type].count
      var received: int = object.get_ammo(delta)
      ammo[object.ammo_type].count += received
      print(“Теперь патронов: “, ammo[object.ammo_type].count)

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

  • Боеприпасы хранятся в карманах по типу. Для этого используется структура Dictionary, или по-русски, словарь. Словарь хранит набор пар с уникальными ключами (в нашем случае типами патронов Ammo.Type), и значениями (в нашем случае абстрактные ящики патронов Ammo). Созданный PistolAmmo.new(25, 100) создаст карман на 100 патронов типа Ammo.Type.Pistol с 25 имеющимися сразу.
  • В функции interact() описан подбор недостающих патронов, если в карманах места меньше, чем можно взять из внешнего объекта.


Добавьте боеприпасы на сцену (рис. 5), и проверьте, подбираются ли патроны.


Рис. 5. Установленный ящик патронов.

Аним. 1. Проверка подобранных патронов.

Переизобретаем пистолет

Пистолет на текущий момент лишь макет, который показывает анимацию, что мы выстрелили. Наполним его смыслом свойствами и функционалом. Начнем с заполнения пустующих классов.

“weapon.gd”:

Pythonclass_name Weapon
extends Item
# Флаг подготовки оружия. Используется для защита от повторного удара и других действий.
@export var in_load: bool = false
# Флаг атаки. Используется для защиты от повторного удара и других действий.
var in_fire: bool

“range_weapon.gd”:

Pythonclass_name RangeWeapon
extends Weapon
signal reloaded(type: Ammo.Type, requested_ammo_count: int)
# Заряженное количество патронов в оружии. Использовать для оружия в мире.
@export var start_ammo: int = -1
# Время до следующего выстрела.
var _remaining_time: float = 0.0
## Время между выстрелами.
var fire_delta: float
# Зажат ли курок.
var is_triggered: bool
# Радиус разброса при стрельбе (пусть фиксированный) в градусах.
var spread_of_fire: float
# Тип применяемых патрон.
var ammo_type: Ammo.Type
# Патронов в магазине.
var ammo_count: int
# Установленный (патронами) урон.
var damage: float


static func get_magazine_size() -> int:
  return -1


# Проверка возможности для следующего выстрела.
func _process(delta: float) -> void:
  _remaining_time -= delta
  if is_triggered and _remaining_time <= 0.0:
    shoot()
    _remaining_time = fire_delta


func on_reloaded() -> void:
  emit_signal("reloaded", ammo_type, get_magazine_size() - ammo_count)


func pull_trigger() -> void:
  is_triggered = true


func release_trigger() -> void:
  is_triggered = false
  in_fire = false


func shoot() -> void:
  pass


func upload_ammo(count: int) -> void:
  ammo_count += count


func set_damage(value: float) -> void:
  damage = value


func is_full() -> bool:
  return ammo_count == get_magazine_size()

Обойдемся без подробностей, как я к такому пришел, лучше потратим время на пояснения. Хоть и комментариев достаточно в коде:

  • static func get_magazine_size() – называется статичным методом класса, когда результат выполнения этого метода не зависит от экземпляра объекта. Грубо говоря, это метод единый и общий сразу для всех возможных объектов этого класса.
  • _remaining_time – переменная обозначается нижним подчеркиванием, если она только для внутреннего использования, типа приватная. Но помним, что это условность, так как в GDScript все переменные публичны.
  • Так как оружие имеет ограничения, по тому, насколько часто из него можно выстрелить, паузы можно считать как по флагам, переключаемым в анимации, так и следить за отсчитанным временем между выстрелами. Это делается каждый кадр… и счетчик FPS на это может повлиять.
  • Так как мы закладываем что-то на будущее, недостаточно просто делать выстрел по переднему фронту сигнала нажатия кнопки (рис. 6) как Input.is_action_just_pressed(action). Нам потребуется в будущем знать и когда отпущена кнопка. А соответственно, разделим стрельбу на 2 события: нажал на курок и отпустил курок. Эти 2 флага, в зависимости от механизма внутри, будут заставлять оружие стрелять. И стрельба у каждого может и будет совершенно разной.


Рис. 6. Элементы цифрового сигнала.


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


А теперь перейдем к пистолету. Важное изменение, что это пистолет определяет, в кого он попадет выстрелом (пули же не выпускаем), а значит Raycast, которым мы “стреляли” из игрока, переезжает в сцену пистолета. В коде же “pistol_gun.gd” напишем следующее:

Pythonclass_name PistolGun
extends RangeWeapon
@onready var animation_player: AnimationPlayer = $AnimationPlayer
@onready var shoot_cast: RayCast3D = $RayCast3D


func _init() -> void:
 fire_delta = 0.25
 ammo_type = Ammo.Type.Pistol
 
 if start_ammo > -1:
  ammo_count = clamp(start_ammo, 0, get_magazine_size())


static func get_magazine_size() -> int:
 return 12


func 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.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


func move() -> void:
 if not animation_player.is_playing():
  animation_player.play("move")


func stop_move() -> void:
 if animation_player.current_animation == "move":
  animation_player.stop()


func reload() -> void:
 if not animation_player.is_playing() or animation_player.current_animation == "shoot":
  animation_player.play("reload")


func stop_animations() -> void:
 animation_player.stop()

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

  • shoot_cast – тот самый рейкаст, переехавший из игрока.
  • get_magazine_size() – переопределен на 12 патронов в обоиме.
  • shoot() – ключевой метод, описывающий поведение при выстрела. Напомню, механизм выстреливает при нажатии на курок в _process(delta) в классе RangeWeapon. Здесь используются все предзаготовленные флаги, которые позволяют контролировать поведение оружия в руках персонажа. Они выведены опытным путем.
  • Используется новая анимация “dryfire” и “reload”. Первая будет воспроизводиться при попытке выстрела с пустым магазином, а вторая будет не только воспроизводить анимацию пистолета при перезарядке, но и участвовать в механизме наполнения магазина боезапасом.
  • Метод “reload()” запускает анимацию перезарядки, а та в свою очередь через сигнал “reloaded()” запросит у игрока пополнение.


Пу-пу-пу. На этом ещё не всё. Анимация перезарядки требует нашего внимания и времени. Давайте взглянем, что же в ней такого?


Помимо обычных изменений положения и вращения пистолета, есть ещё 2 трека (рис. 7):

  • вызов метода on_reloaded(), которая как раз и вызывает сигнал запроса патронов у персонажа;
  • изменение состояния флага in_load, который определяет “занятость” оружия для выстрела и прерывания другими анимациями.


Рис. 7. Дорожка вызова метода и дорожка свойства PistolGun.


Если выбрана нижняя вкладка “Анимация”, дорожку свойства можно быстро создавать из инспектора, нажав на ключик справа от значения свойства. Ключ создастся автоматически в месте, где сейчас установлена временная шкала.


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


Во-первых, нам необходимы дополнительное действие “reload” и обновление логики в контроллере персонажа в _unhandled_input(event):

Python if event.is_action_pressed("action"):
  character.action()
 elif event.is_action_released("action"):
  character.sub_action()
 
 if event.is_action_pressed("interact"):
  character.interact()
  
 if event.is_action_pressed("reload"):
  character.reload_gun()

Во-вторых, надо удалить старый код cast() у “character.gd” и добавить новый:

Python# Callback для контроллера игрока по действию "Action".
func action() -> void:
 fire()


# Callback для контроллера игрока по прекращению действия "Action".
func sub_action() -> void:
 stop_fire()


# Обеспечивает стрельбу из оружия.
func fire() -> void:
 gun.pull_trigger()


# Прекращает стрельбу из оружия.
func stop_fire() -> void:
 gun.release_trigger()
…


# Перезаряжает текущее оружие.
func reload_gun() -> void:
 if not gun.is_full() and ammo[gun.ammo_type].count > 0:
  gun.reload()


# Callback для заряжаемого оружия.
func _on_weapon_reloaded(type: Ammo.Type, requested_count: int) -> void:
 var taken_count: int = clamp(requested_count, 0, ammo[type].count)
 gun.upload_ammo(taken_count)
 gun.set_damage(ammo[type].damage)
 ammo[type].count -= taken_count
	print("Оставшийся боезапас: ", ammo[type].count)

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

  • _on_weapon_reloaded(type, requested_count) – callback-метод для того самого сигнала, который посылается оружием в момент перезарядки. Его потребуется присоединить от оружия к персонажу (рис. 8).


Рис. 8. Присоединение сигнала на запрос боеприпасов оружием.


Много кода, много работы, и если запустить проект… это просто должно работать!

Аним. 2. Проверка работоспособности.


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


hub.comments