С вами
Дмитрий Горин, ментор по 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Пояснения к коду:
На всякий случай проговорим про 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Пояснения к коду:
Прелесть этого класса будет в том, что его описанные свойства подойдут как для боеприпаса, который хранится у персонажа и будет использоваться в оружии, так и подойдет для ящика патронов, который будет стоять на карте (а то и для работы в инвентаре). Я рассматриваю сущность 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Пояснения к коду:
Рис. 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)Пояснения к коду:
Добавьте боеприпасы на сцену (рис. 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()Обойдемся без подробностей, как я к такому пришел, лучше потратим время на пояснения. Хоть и комментариев достаточно в коде:
Рис. 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()Пояснения к коду:
Пу-пу-пу. На этом ещё не всё. Анимация перезарядки требует нашего внимания и времени. Давайте взглянем, что же в ней такого?
Помимо обычных изменений положения и вращения пистолета, есть ещё 2 трека (рис. 7):
Рис. 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)Пояснения к коду:
Рис. 8. Присоединение сигнала на запрос боеприпасов оружием.
Много кода, много работы, и если запустить проект… это просто должно работать!
Аним. 2. Проверка работоспособности.
А для удобства оставляю здесь архив с проектом, с которого писалось руководство.