translatedTitles.Article
DG
Dmitriy Gorin
11/19/25

Godot. Урок 2. Организация проекта и классы. Контроллер игрока, стрельба и анимация

С вами

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

- Разработчик графических подсистем в АТОМ и инди-разработчик игр

- Участвую экспертом в мероприятиях и хакатонах, посвященных разработке игр

- Веду образовательную деятельность при университете РГРТУ им. В.Ф. Уткина (группа ВК)


Вопросы по движку и программе можно задавать в соответствующем топике Godot чата Практик•ON (https://t.me/+gthG03srlcFlMzky).



Предисловие

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


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


Организация проекта

В нашей игре будет много разновидностей файлов, которые зависят и от выбранного движка. В Godot у вас объекты и разные сущности представляются в виде сцены “tscn” и/или скрипта “gd”.


Для шутера предлагаю создать в корне проекта 3 папки:

  • objects – для всех объектов, из которых будет состоять мир игры;
  • resources – для всех ресурсов, таких как спрайты, иконки, материалы, музыка, и прочее подобное;
  • ui – для всех разрабатываемых интерфейсных окон и меню.

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


У нас есть уровень, и персонаж в нем, которым мы управляем. Они сейчас единое целое, и надо это исправить, так как наш персонаж может существовать и вне этого уровня. Переименуйте корневой узел игрока в “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()

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

  • Мы зарегистрировали класс Character в нашем проекте. Таким образом мы можем типизировать наш объект, и это удобно использовать при проверках, а также позволяет корректно работать автодополнению кода в редакторе скриптов движка.
  • Константы заменены на переменные с префиксом @export. Это удобно для настройки персонажа, так как обозначенные таким префиксом переменные отображаются в инспекторе (рис. 5).
  • smoothing_movement будет использоваться для плавности движения героя, придавая инертности.
  • Весь код установления поворота взгляда и направления движения обернут в глобальные переменные скрипта move_direction и gaze_direction, у которых есть “set/get” методы. Создание таких методов позволяет при установлении или взятия значения переменной выполнять дополнительные действия и проверки.
  • Ранее используемый _physics_process(delta) заменен на _process(delta) (вызывается каждый рисуемый кадр), а в задачи этой функции теперь входит только обрабатывать перемещение.


Рис. 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)

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

  • character – это ссылка на управляемый контроллером объект. У него будут вызываться все функции.
  • is_enabled – флаг для отключения/включения контроллера.
  • "move_left", "move_right", "move_forward", "move_backward" – действия, что я описал для управления персонажем (рис. 6).
  • В обработчике ввода _unhandled_input(event) вызываем функции персонажа, если он доступен и контроллер включен.


Рис. 6. Настройки “действий”.


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


Прошу не забыть, что персонаж не указан явно в переменной character. Укажем узел персонажа, который содержит необходимые функции для управления им, в инспекторе. Нажмите на “Назначить” рядом с переменной и в открывшемся окне выберите подходящий узел (рис. 7). Должен быть только один вариант.


Рис. 7. Назначение персонажа для управления.


Скрипт управления пользователем сделан для игрока, а вот нынешний скрипт “character.gd” запланирован универсальным. Исходя из этого “character.gd” переместится в папку “objects/characters”.


Перейдем к расширению возможностей!


Стрельба

Начнем с базового: а какой будет подход при стрельбе?

Пусть у нас будет FPS с огнестрельным оружием. Скорости пуль, обычно, огромные, и её попадание до цели можно обозначить как мгновенное. Этот сценарий будет самым удобным, и сейчас покажу почему.


RayCast

Для начала, давайте научим персонажа видеть то, что за объект находится перед ним и влиять на этот объект. Нам поможет 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, ". Руки вверх!")

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

  • Переменная ray для удобства обращения к RayCast3D.
  • Метод cast() описывает проверку, что если луч с чем-то пересекся, вывести объект в консоль вывода (рис. 9).


Нет смысла делать проверку каждый кадр. Достаточно по привычному для стрельбы действию: ПКМ. Для перечисления логики при нажатии ПКМ и создан метод action().


Чтобы метод action() заработал, потребуется:

  • Создать новое действие в списке действий;
  • Добавить обработчик нового действия в скрипт “user_input_controller.gd” в _unhandled_input(event).

С последним помогу:

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()

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

  • Обратите внимание что класс наследуется не от StaticBody3D, которым является болванчик, а от PhysicsBody3D. Это общий класс для разных физических тел в движке (рис. 13) и реализует инструменты для работы с ними. Это значит, что будет подходить для большего типа объектов. У этого есть минус, что если мы попытаемся получить доступ к переменным и методам, который есть в унаследованном StaticBody3D, мы их не увидим в нашем DestructibleObject (доступ к ним возможен), но для такого простого объекта нам это и не потребуется.
  • Метод queue_free() ставит в очередь на удаление со сцены объект. При этом удаляются и все дочерние элементы этого объекта.


Рис. 13. Структура наследования PhysicsBody3D.


Расположу сцену этого болванчика в структуре проекта в папку “objects/world_objects/dummy/”, потому что задача этого объекта – быть элементом окружения, который будет реагировать на нашу стрельбу. Скрипт “destructible_object.gd” пойдет в “objects/world_objects/”, так как этот класс может использоваться в дальнейшем для множества объектов игрового мира.


Пушка

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

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


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


Рис. 14. Подбираемые предметы в Brutal Doom.


И раз предметы могут отличаться по своему поведению и применению, то стоит их и разделить на соответствующие подклассы и распределить по своим папкам:

  • Для оружия дальнего боя “objects/items/weapons/range/”;
  • Для оружия ближнего боя “objects/items/weapons/melee/”;
  • Для патронов “objects/items/ammo/”;
  • Для здоровья, брони и иных модификаторов “objects/items/powerups/”.


Пистолет будет относиться к оружию дальнего боя. А значит, создайте сцену для пистолета (классы отложим на потом). Для создания меша я буду использовать набор 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. Проверка результата.

hub.comments
2


zS
zen Shaman
11/20/25

За контент спасибо, но скрины такие мелкие, что глаза можно сломать, пытаясь разглядеть чего там (

Как и в первой статье...


zS
zen Shaman
11/20/25

Делюсь лайфкахом от мелких картинок )

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