С вами
Дмитрий Горин, ментор по Godot
- Разработчик графических подсистем в АТОМ и инди-разработчик игр
- Участвую экспертом в мероприятиях и хакатонах, посвященных разработке игр
- Веду образовательную деятельность при университете РГРТУ им. В.Ф. Уткина (группа ВК)
Вопросы по движку и программе можно задавать в соответствующем топике Godot чата Практик•ON (https://t.me/+gthG03srlcFlMzky).
В прошлой части мы закончили на простой игровой сцене из нескольких примитивов и игрока, и всё это сохранено прямо в корне проекта. Так делать, конечно не запрещено, но желательно уметь правильно организовывать проект, тем более что он к концу курса может стать немаленьких размеров и содержать множество файлов. Поэтому хорошо бы спланировать, где и как будут располагаться папки и файлы в вашем проекте.
Классы, о которых тоже пойдет речь, имеют непосредственную связь с организацией проекта, так как наполнение этих классов и организованная взаимосвязь между объектами классов влияет на многое. Вид наполнения и взаимосвязи очень зависит от вашего видения и мышления, а также других личных качеств и предпочтений и может отличаться от видения автора в этом вопросе. Рекомендую в рамках данного курса придерживаться видения автора 😀.
В нашей игре будет много разновидностей файлов, которые зависят и от выбранного движка. В Godot у вас объекты и разные сущности представляются в виде сцены “tscn” и/или скрипта “gd”.
Для шутера предлагаю создать в корне проекта 3 папки:
Не всё из этого будет заполняться и детализировано в данный момент, но что-то можно начать уже сейчас. Папки можно создать прямо из движка.
У нас есть уровень, и персонаж в нем, которым мы управляем. Они сейчас единое целое, и надо это исправить, так как наш персонаж может существовать и вне этого уровня. Переименуйте корневой узел игрока в “Player”, если вы забыли это сделать, как я на скриншотах из первой части, и нажмите ПКМ (правую кнопку мыши). В выпавшем меню выберите “Сохранить ветку как сцену” (рис. 1) и сохраните персонажа, пока снова в корень проекта с названием “player.tscn”. Флаги на “Сбросить позицию”, “Сбросить вращение”, “Сбросить масштаб” можно оставить как есть.
Рис. 1. Сохранение части сцены с игроком в отдельную сцену.
Сохраненный как отдельная сцена игрок является персонажем, которых в игре будет не так уж и мало. Точно больше одного. При этом “Player” является и объектом этого игрового мира. Исходя из этой цепочки рассуждений, кладу его сцену и скрипт в папку “objects/characters/player/” (рис. 2).
Теперь, когда игрок выделен как отдельная сцена-объект, можно расположить тестовый уровень также в свою папку: “objects/maps/test_scene/” (рис. 2).
Рис. 2. Новая структура папок и файлов.
Классы – это абстракция, которая используется для описания шаблона одинаковых или родственных объектов. В нем описываются свойства и методы объекта, а также за счет наследования классов можно дополнять и расширять один класс другим. О том как это работает вы постепенно будете узнавать в данном курсе.
Начнем как раз с нашего игрового персонажа. В игре будут и другие существа, которые могут двигаться и поворачиваться, а то и взаимодействовать с предметами, окружением и стрелять, чего наш герой пока не умеет. Отличие между нашим героем и другими NPC – это различие контроллера: в первом случае мы сами принимаем решение и через клавиши управления воздействуем на объект персонажа, а во втором – заранее записанный алгоритм поведения (рис. 3).
Рис. 3. Различия игрока и неиграбельных персонажей.
По этой причине я решил выделить методы управляемой нами “куклы”, в отдельный класс, и прицепить ему контроллер для нашего управления. Во-первых, рекомендую добавить простой узел Node3D для позиционирования головы (так и назовите, “Head”), так как на неё мы будем вешать много чего в будущем, и так проще позиционировать, вращать “всю голову”. Добавьте простой узел Node, который будет ответственен за передачу управления от клавиатуры и мыши к персонажу. Назовем его UserInputController. Должно получиться как на рисунке 4.
Рис. 4. Добавленные узлы на сцену.
Переделаем скрипт игрока следующим образом:
Pythonclass_name Character
extends CharacterBody3D
@export var base_speed: float = 2.0
@export var base_jump: float = 2.0
@export var smoothing_movement: float = 0.1
# Направление движение относительно взгляда gaze_direction.
var move_direction: Vector2 = Vector2.ZERO : set = move_body, get = get_move_direction
# Направление взгляда.
var gaze_direction: Vector2 = Vector2.ZERO : set = move_gaze, get = get_gaze_direction
var gravity: float = ProjectSettings.get_setting("physics/3d/default_gravity")
# Состояние запроса на прыжок.
var jump_state: bool = false : set = try_jump, get = is_trying_jump
# Ссылка на узел головы.
@onready var head: Node3D = $Head
# Напрямую укажем режим захвата мыши в игре.
func _ready() -> void:
Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)
# Указывает вектор перемещения существа. vector должен быть нормализованный!
func move_body(vector: Vector2) -> void:
move_direction = vector
func get_move_direction() -> Vector2:
return move_direction
func move_gaze(vector: Vector2) -> void:
gaze_direction = vector
func get_gaze_direction() -> Vector2:
return gaze_direction
func try_jump(state: bool = true) -> void:
jump_state = state
func is_trying_jump() -> bool:
return jump_state
func _process(delta: float) -> void:
# Расчет изменения движения за кадр.
var smooth_delta: float = base_speed * delta / smoothing_movement
# Текущая скорость движения.
var velocity_dir:= Vector3(velocity.x, 0, velocity.z)
# Если двигают,
if not move_direction == Vector2.ZERO:
# то рассчитать целевое движение
var movement_dir: Vector3 = transform.basis * Vector3(move_direction.x, 0, move_direction.y)
# и рассчитать плавное движение.
var smooth_dir: Vector3 = velocity_dir.move_toward(movement_dir * base_speed, smooth_delta)
velocity.x = smooth_dir.x
velocity.z = smooth_dir.z
else:
var smooth_dir: Vector3 = velocity_dir.move_toward(Vector3.ZERO, smooth_delta)
velocity.x = smooth_dir.x
velocity.z = smooth_dir.z
if not gaze_direction == Vector2.ZERO:
rotate_y(gaze_direction.x)
head.rotation.x = clamp(head.rotation.x + gaze_direction.y, -PI / 2, PI / 2)
gaze_direction = Vector2.ZERO
if not is_on_floor():
velocity.y += -gravity * delta
elif jump_state:
velocity.y = base_jump
move_and_slide()Пояснения к коду:
Рис. 5. Экспортированные переменные в инспекторе.
Есть хорошая практика, что название файла должно совпадать с названием класса в нем описанном. Переименуем файл из “characted_body_3d.gd” в “character.gd” в соответствии со стилем именования файлов.
Наш персонаж настроен для управления, но его пока не получает. Нужно запрограммировать его контроллер. Создадим скрипт для заранее приготовленного узла “UserInputController” со следующим содержимым:
Pythonextends Node
@export var character: Character
@export var mouse_sensetivity: float = 0.002
var is_enabled: bool = true
func _unhandled_input(event: InputEvent) -> void:
if not is_instance_valid(character) and is_enabled:
return
character.move_body(Input.get_vector("move_left", "move_right", "move_forward", "move_backward"))
# Если действие нажато,..
if event.is_action_pressed("jump"):
character.try_jump()
# Когда отпущено,..
elif event.is_action_released("jump"):
character.try_jump(false)
if event is InputEventMouseMotion:
character.move_gaze(-event.relative * mouse_sensetivity)Пояснения к коду:
Рис. 6. Настройки “действий”.
Если выставить скорость и прыжок как в предыдущей части курса, то несмотря на многие изменения в коде, в запущенном проекте… разницы не будет. Данное разделение позволит в дальнейшем удобно развивать возможности нашего персонажа, а также использовать данный код и для других существ.
Прошу не забыть, что персонаж не указан явно в переменной character. Укажем узел персонажа, который содержит необходимые функции для управления им, в инспекторе. Нажмите на “Назначить” рядом с переменной и в открывшемся окне выберите подходящий узел (рис. 7). Должен быть только один вариант.
Рис. 7. Назначение персонажа для управления.
Скрипт управления пользователем сделан для игрока, а вот нынешний скрипт “character.gd” запланирован универсальным. Исходя из этого “character.gd” переместится в папку “objects/characters”.
Перейдем к расширению возможностей!
Начнем с базового: а какой будет подход при стрельбе?
Пусть у нас будет FPS с огнестрельным оружием. Скорости пуль, обычно, огромные, и её попадание до цели можно обозначить как мгновенное. Этот сценарий будет самым удобным, и сейчас покажу почему.
Для начала, давайте научим персонажа видеть то, что за объект находится перед ним и влиять на этот объект. Нам поможет RayCast3D – палочка-проверялочка на пересечения с объектами и областями.
Добавим этот узел на узел головы. По умолчанию в инспекторе вы увидите свойства “включен”, “исключить родителя” и “целевое положение”. У каждого из них вы можете посмотреть описание, если наведете на название свойства в инспекторе. Вдруг ещё не проверили… По умолчанию положение указано вниз, y = -1.0, предлагаю запустить луч подальше и в направлении вида камеры, как на рисунке 8.
Рис. 8. Настройка RayCast3D.
Этот инструмент готов к работе, осталось только им воспользоваться. Опишем метод в скрипте “characted.gd”:
Python@onready var ray: RayCast3D = $Head/RayCast3D
…
func action() -> void:
cast()
func cast() -> void:
ray.force_raycast_update()
if ray.is_colliding():
var collider = ray.get_collider()
print("На глаза попался ", collider, ". Руки вверх!")Пояснения к коду:
Нет смысла делать проверку каждый кадр. Достаточно по привычному для стрельбы действию: ПКМ. Для перечисления логики при нажатии ПКМ и создан метод action().
Чтобы метод action() заработал, потребуется:
С последним помогу:
Python if event.is_action_pressed("action"):
character.action()Если всё сделано верно, в запущенном проекте при клике будет выводиться в консоль вывода сообщение с нашим текстом (рис. 9).
Рис. 9. Успешный вывод в консоли.
Если вы хотите увидеть этот луч, а то и другие коллизии объектов и областей, то можно включить их отображение через верхнее меню “Отладка → Видимые формы столкновений” (рис. 10). Правда целеуказатель с таким RayCast3D будет так себе (рис. 11). Нормальный будет потом.
Рис. 10. Включение видимости коллизии объектов и областей.
Рис. 11. Вид луча из глаз в момент пересечения с объектом.
Нам нужен интерактивный объект, по которому мы сможем в дальнейшем пострелять. Необязательно это должен быть другой персонаж, NPC, пусть это будет буквально примитивный куб без поведения.
Создадим сцену со статичным (StaticBody3D узел) объектом, но для выделения его из серой белой массы уже присутствующих мешей на сцене надо придать ему красок. У созданного меша в MeshInstance3D добавим материал StandardMaterial3D (рис. 12), и во вкладке “Albedo → Color” установите цвет. Я выбрал “dc4b6e”, а сцену назвал “Dummy” (болванчик).
Рис. 12. Создание материала для меша.
Опишем класс DestructibleObject в файле “destructible_object.gd”. Он лишь будет иметь параметр здоровья и метод для его изменения:
Pythonclass_name DestructibleObject
extends PhysicsBody3D
var health: float = 1.0
func change_health(value: float) -> void:
health -= value
if health <= 0.0:
queue_free()Пояснения к коду:
Рис. 13. Структура наследования PhysicsBody3D.
Расположу сцену этого болванчика в структуре проекта в папку “objects/world_objects/dummy/”, потому что задача этого объекта – быть элементом окружения, который будет реагировать на нашу стрельбу. Скрипт “destructible_object.gd” пойдет в “objects/world_objects/”, так как этот класс может использоваться в дальнейшем для множества объектов игрового мира.
Можно было бы уже пострелять лучом по болванчикам, но без некоторой отдачи и визуализации это не интересно. Нужно смастерить ствол, которым мы будем угрожать близлежащим кубам. Пусть это будет пистолет.
Могли бы пойти простым путем, но пойдем длинным для растяжения времени урока: порассуждаем, что же будет пистолет как сущность в данной игре?
Помимо карт и разрушаемых объектов в них, и бегающих по ним персонажей, в игре будет что-то, что можно подбирать и, автоматически или вручную, использовать. Я говорю о предметах (Item). Если брать базированные примеры старых игр, то аптечки, патроны, оружие, ключ-карты – это всё предметы, располагаемые на уровне, и они либо могут влиять на игрока мгновенно, либо накапливаться и использоваться при необходимости.
Рис. 14. Подбираемые предметы в Brutal Doom.
И раз предметы могут отличаться по своему поведению и применению, то стоит их и разделить на соответствующие подклассы и распределить по своим папкам:
Пистолет будет относиться к оружию дальнего боя. А значит, создайте сцену для пистолета (классы отложим на потом). Для создания меша я буду использовать набор MeshInstance3D и из примитивов соберу что-то вроде того, что на рисунке 15.
Рис. 15. Собранный прототип пистолета.
Во-первых, обратите внимание на новый узел, добавленный на сцену: AnimationPlayer. Этот узел позволит создавать и воспроизводить анимации этой модели.
Во-вторых, для удобства предпросмотра измените вид вьюпорта, разделив его на 2 окна (рис. 16), и добавьте камеру на сцену пистолета. При выборе камеры, на каждом окне вьюпорта появится флажок “Предпросмотр”. Включите на одном из окон предпросмотр и теперь вы видите сцену и в свободном виде, и как она будет представлена у игрока (рис. 17).
Рис. 16. Изменение вида окна вьюпорта.
Рис. 17. Разделенный экран вьюпорта с предпросмотром.
Самое время переходить к анимированию.
Когда на сцене есть AnimationPlayer узел, появляется вкладка окна “Анимация” в нижней части экрана. На неё можно перейти, кликнув по самому узлу AnimationPlayer, или нажав на ранее упомянутую кнопку “Анимация” (рис. 18).
Рис. 18. Вкладка окна “Анимация”.
По кнопке “Анимация” рядом с названием анимации “Reset” можно создать новую анимацию, дублировать,.. ну, вы и сами можете прочитать список. Создайте новую анимацию “move”, начнем с простого движения всего оружия при движении игрока.
Перед нами пустая временная шкала анимации. По умолчанию она длиной в 1с, настраивается в левом верхнем углу вкладки “Анимация”. Нажмите на плюсик в левой части временной шкалы, и выберите “дорожка положения 3D”. Перемещать нужно не всю сцену оружия, а только лишь его модель, в моем случае это меш “Barrel”. Нажмите в начале временной шкалы, у 0.0, ПКМ, и возникнет меню для работы с ключевыми кадрами (рис. 19) – кадрами, в которых мы будем фиксировать положение оружия во времени, а движок будет самостоятельно перемещать объект каждый отрисованный кадр между ключевыми.
Рис. 19. Меню управления ключевыми кадрами.
На вопрос “Создавать ли дорожку сброса RESET” соглашайтесь. В инспекторе, если выделен добавленный ключ, появится значение положения, в котором находится пистолет в этом ключевом кадре. Перемещаете ползунок времени в конечное положение для пистолета, отодвигаете пистолет на сцене, фиксируете ключевым кадром (аним. 1).
Аним. 1. Анимирование по ключевым кадрам.
Чтобы анимация при воспроизведении повторялась, справа от поля для указания времени, есть иконка зацикливания. На анимации 1 она установлена в режим зацикливания с повторением в обратную сторону.
Сделайте аналогично анимацию “shoot”.
Когда есть анимации, можно написать скрипты по классам, о которых мы рассуждали в разделе Пушка.
Пока оставим почти все классы пустыми. Выглядеть будет как на рисунке 20.
Рис. 20. Наплодил сущностей и доволен.
В классе Item будет:
Pythonclass_name Item
extends NodeВ классе Weapon будет:
Pythonclass_name Weapon
extends ItemВ классе RangeWeapon будет:
Pythonclass_name RangeWeapon
extends WeaponВ файле “pistol_gun.gd”:
Pythonextends RangeWeapon
@onready var animation_player: AnimationPlayer = $AnimationPlayer
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 shoot() -> void:
animation_player.stop()
animation_player.play("shoot")Позднее в пустых классах будут описываться общие возможности для каждого вида предметов. Переходим к сборке всего этого вместе.
Добавим пистолет на “голову” игрока и в коде пропишем:
Python@onready var gun: RangeWeapon = $Head/PistolGun
…
func cast() -> void:
ray.force_raycast_update()
if ray.is_colliding():
var collider = ray.get_collider()
print("На глаза попался ", collider)
if collider.has_method("change_health"):
collider.change_health(2.0)
gun.shoot()
print("Бах!")
…
func _process(delta: float) -> void:
…
if move_direction:
…
# Двигать пистолет, если двигаемся сами.
gun.move()
else:
…
# Не двигать пистолет, если стоим сами.
gun.stop_move()
…Добавьте болванчиков, можно прямо перетащив файл сцены “dummy.tscn” на вьюпорт тестовой сцены... и в этой части курса всё. Попробуйте пострелять по ним. Должно сработать.
Аним. 2. Проверка результата.
Делюсь лайфкахом от мелких картинок )
В браузере жмем контрол + несколько раз, до тех пор, пока картинки не станут читабельны, ибо автор старался и сами картинки достаточного разрешения, но в превью отображаются похабнейше (
За контент спасибо, но скрины такие мелкие, что глаза можно сломать, пытаясь разглядеть чего там (
Как и в первой статье...