Больше функционала для минималистичного прототипа игры: объекты уровня, враги, апгрейд управления, глобальный скрипт и статичные выстрелы.
Архив с готовыми файлами проекта можно взять на странице: https://thenonsense.itch.io/opendungeon
Open Dungeonthenonsense.itch.io
В этой статье мы пошагово обновим прототип alfa до состояния beta:
Запускаем Godot. Открываем проект Open Dungeon, который мы создали ранее в части alpha . Открывается сцена MainScene.
Уровень
Добавим к Main (корневой узел сцены) новый узел Spatial. Перетащим в него все копии Pol и Pillar, затем переименуем этот узел в Level_A1.
Щёлкаем по узлу правой кнопкой, выбирая Save Branch as Scene. Делаем двойной щелчок на папке Prefabs (входя в неё) затем нажимаем сверху справа Create Folder. Пишем имя папки — Levels. Ок. Сохраняем префаб уровня.
Теперь на основной сцене остался только узел level_A1, все вложенные элементы сжались внутрь него. В Godot можно включить возможность редактирования содержания сцены-префаба, не открывая его полностью (в меня по правой кнопке нужно отметить пункт Editable Children), но мы не будем этого делать и для редактирования содержимого добавленных сцен-префабов будем открывать их в отдельном окне.
Откроем Level_A1 отдельной сценой, щёлкнув по значку рядом с глазом.
Выделим узлы Pol_A и Pol_A2, скопируем (Ctrl + D). Справа на панели инспектора в разделе Transform в поле Translation допишем к 0 в ячейке Z ещё минус 10 и нажмём ввод (Enter). Каждый скопированный кусочек сдвинется на минус 10 пунктов, и мы получим уже 4 примыкающих друг к другу куска пола.
Когда нужно изменить числовые параметры в инспекторе (прибавить отнять определённое число), то вместо рассчёта и написания нового результата можно дописывать в поле к текущему параметру + число или — число, чтобы Godot вычислил новый результат автоматически. Если выделены несколько объектов, то изменение параметров полей в инспекторе будет касаться сразу их всех (если, конечно, объекты однотипные, или когда конкретный редактируемый параметр есть у всех выделенных объектов).
Также скопируем три узла Pillar и отодвинем их дальше за синюю стрелку по оси Z.
Сундуки
Выделим отдельно последний узел Pillar_A6 и разберём его обратно, на составляющее, достав из связанного состояния. Для этого нажмём на узел правой кнопкой и выберем пункт Make Loсal.
Растребушив префаб удаляем нижний приплюснутый кубик (MeshInstance3), колонну уменьшаем до куба и сдвигаем вниз, верxний сплющенный куб опускаем сверху, сохраняя на небольшом расстоянии.
Зайдём в поле Mesh в каждом из этих двух MeshInstance, выбрав там опцию Make Unique. Визуально ничего не изменится, однако таким образом мы переназначили сеткам форму и она теперь не привязана к форме, оставшейся у кубиков внутри префабов с колоннами.
Также нужно нажать на узел CollisionShape и тоже выбрать опцию Make Unique в выпадающем списке для его Shape. Если этого не сделать, то вы сами можете заметить, что коллизия связанна с коллизиями колонн — потянув за точку куба и увидев, что полупрозрачные кубики колонн тоже тянутся. После применения Make Unique эта коллизия станет уникальной, не связанной с прочими.
Переименовываем узел Pillar_A6 в Chest_A. В поле Translation в инспекторе нажимаем значок сброса кооординат, чтобы Chest_A оказался в начале координат (0 по всем трём осям).
Находим его. И превратим в префаб (правая кнопка, Save Branch as Scene).
Сейчас у нас открыта папка Levels внутри Prefabs, поднимемся выше и сохраняем Chest_A сюда. Также сохраним текущую сцену Level_A1 (Верхняя панель редактора — Scene — Save Scene).
Зайдём теперь внутрь Chest_A, щелкнув по соответствующему значку.
Выделим узел Area, и в инспекторе в ячейках Layer у Collision оставим включенной только ячейку 3, таким образом коллизия сундуков будет отличаться от коллизии колонн, располагаясь в другом слое.
Выделим корневой узел Chest_A и добавим новый узел Spatial. Переместим его немного вверх и вправо по оси Z, чтобы он оказался на боковой стороне нашего квадратного сундука, в щели между основанием и «крышкой».
Теперь перетащим «крышку» (MeshInstance4) внутрь Spatial.
Выделим узел Spatial и повернём его по оси X потянув за красный круг (как бы приоткрывая «крышку»). Или же можно вбить угол поворота в редакторе (набираем в инспекторе для Rotation Degrees -25 в ячейке X).
Сохраним текущую сцену. Закроем её вкладку, нажав на крестик рядом с названием.
Оказываемся снова в сцене уровня. Сделаем пару копий Chest_A (Сtrl + D) сдвигая каждую немного по Z. Затем выделим все и переместим правее по оси X, в сторону от колонн.
Запустим игру. Персонаж проходит сквозь сундуки, так как мы перевели их в слой, на который не реагирует рейкаст персонажа. Так пока и оставим.
Заготовка для врага
Сделаем копию последнего сундука Chest_A3. Выделим получившийся Chest_A4 и сбросим его Translation в инспекторе.
Щелкаем по Chest_A4 правой кнопкой и разбираем его на составляющие (Make Local).
Переименуем узел в Enemy_red.
У обоих кубиков (MeshInstance2 и MeshInstance4) в поле Shape инспектора применим Make Unique.
А вот в узле CollisionShape выберем другую форму коллизии вместо текущей кубической — SphereShape. Радиус сферы установим в 1.5.
Теперь нажимаем правой кнопкой на узле Enemy_red и сжимаем его в сцену-префаб (правая кнопка, Save Branch as Scene). Сохраняем в ту же текущую папку (Prerfabs) под предлагаемым именем.
Сохраняем текущую сцену уровня. Заходим внутрь сцены Enemy_red. Выделим первый кубик (MeshInstance2), откроем вкладку Material в инспекторе. Щелкнув на пустом поле создадим новый материал, выбрав New SpatialMaterial.
Щёлкаем по белой сфере и в открывшихся подробных настройках нового материала открываем вкладку Albelo. Устанавливаем красный цвет в поле Color.
Сохраняем материал, нажав на иконку дискетки и выбрав Save As…
Щёлкаем два раза по папке Materials, меняем имя на emeny_red_mat и жмём сохранить.
Выделим ещё раз узел MeshInstance2, чтобы появились его настройки в инспекторе. Кликаем по выпадающему списку на красной сфере в его поле material и выбираем опцию Copy.
Теперь выбираем следующий кубик (MeshInstance4), открываем его вкладку Material и в выпадающем списке у поля empty (либо по правой кнопке) выбираем самый нижний вариант — Paste. Красный материал копируется, и теперь оба кубика красные. Сохраним пока сцену.
Настало время настроить слои коллизий, чтобы они стали более информативными. Для этого идём в Project — Project Settings…
Откроется окошко с кучей настроек. Проматываем левую колонку вниз и в Layer Names кликаем на строку 3D Physics. В полях справа дописываем свои названия для некоторых слоёв. Layer 2 — solid object, Layer 3 — chest, Layer 4 — enemy, Layer 5 — player. Закрываем настройки, нажав внизу кнопку close. Выделим теперь узел Area, откроем в инспекторе вкладку Collision, наведём на отмеченную ячейку — там горит подсказка, что слой 3 это chest (сундук). Но в данном случае мы делаем префаб врага, поэтому выключим слой 3 и включим 4 — enemy. Теперь добавим врагу управляющий скрипт. Выделим корневой узел Enemy_red и нажмём на свиток с зелёным плюсиком правее и выше. Жмём на иконку папки, поднимаемся из папки Prefabs на уровень выше и заходим в папку scripts, где уже лежит скрипт основной сцены — Main.gd. Правим название на theAI_red и нажимаем Open. Теперь можно нажать Create. Попадаем в новый созданный скрипт. Внизу можно нажать на стрелочку, чтобы показать/скрыть панельку с перечнем текущих используемых скриптов. Видим там, что у нас сейчас выбран скрипт theAI_red.gd. Удаляем всё, что ниже первой строчки и пишем строки: Сохраняем текущий выбранный скрипт, щёлкнув по File — Save. Запускаем игру (Кнопка Play, горячая клавиша F5). Видим, что враг появляется в той же точке, что и игрок, и всё время убегает вправо.func _physics_process(delta):
self.translation.x += delta
Глобальный скрипт и враг-преследователь
Теперь заведём глобальный одиночный скрипт, который будет загружаться вне сцен и хранить какие-то данные, которые можно будет увидеть из прочих скриптов, где бы они не находились. Для этого щёлкнем на строке res:// в небольшом левом окошке с иерархией ресурсов. Нажимаем кнопку New Script.
Далее щёлкаем на значок папки. Меняем предложенное название скрипта на букву G (G.gd) и нажимаем Open. Создаём скрипт, нажав на Create.
Теперь откроем его, сделав двойной клик по G.gd в списке ресурсов.
Уберём все закомментированные строки и выше _ready() напишем строку var player_place = Vector3.ZERO . Сохраним скрипт. Таким образом сейчас у нас есть переменная player_place для хранения координат, доступная в остальных скриптах по имени G.player_place.
Заходим в Project — Project Settings…
Переключаемся там на вкладку AutoLoad. Нажимаем на значок папки. Выбираем G.gd. Open. Далее нажимаем самую правую кнопку Add. Появляется строчка с параметрами. Закрываем окно, нажав внизу на Close. Теперь скрипт G стал доступен из других скриптов по своему имени. Переключимся в скрипт основной сцены theMain.gd. Допишем ниже строки func _process() пару строк с новой функцией, реагирующей на события ввода: Если пользователь что-то нажимает, то мы будем отправлять координаты героя (main_hero.global_translation) в переменную глобального скрипта (player_place). Сохраняем скрипт. Переключаемся в theAI_red.gd. Заменим весь его код следующим: Запускаем игру. Сейчас враг стал преследовать игрока по его координатам, вращаясь вокруг своей оси, если подошёл совсем близко. Движется враг «спиной», поэтому немного подправим его сцену. Переключаемся в режим 3D, оказавшись на сцене Enemy_red. Выделим корневой узел (Enemy_red) и создадим новый Spatial (автоматически переименуется в Spatial2, так как выше есть узел с таким именем) Переименуем созданный узел в Visual и сбросим в него все прочие узлы, которые были внутри корневого. Теперь выделим узел Visual и во вкладке Rotation Degrees инспектора поставим поворот на 180 по Y. Запускаем игру, враг теперь поворачивается правильной стороной. Немного упростим запись предустановленных векторов. Переключимся на скрипты и допишем в G.gd после первой строки упрощённые записи единичных векторов по основным осям: Сохраним скрипт и вернёмся в theAI_red.gd, переписав его следующим образом (ничего не меняя в логике, просто немного упростив запись): То есть, например, вместо записи Vector3.RIGHT мы теперь используем G._Y Сохраняем скрипт. Закрываем сцены Enemy_red и Level_A1 (щелкая по крестикам). Оказываемся в главной сцене, переключаемся на 3D. Добавим источник света, чтобы картина стала повеселее. Щёлкаем на узел Hero, добавляем ему новый узел OmniLight. Поднимем источник света выше (примерно на 3 по y). В настройках источника света в инспекторе увеличим Range до 7 и включим галочку Enabled во вкладке Shadows (чтобы источник света отбрасывал тени, а не только добавлял освещённости). Запускаем игру. Теперь всё смотрится бодрее.func _unhandled_input(event):
G.player_place = main_hero.global_translation
extends Spatial
var speed = 1.0
func _physics_process(delta):
self.look_at(Vector3(G.player_place.x,self.translation.y,G.player_place.z), Vector3.UP)
self.translate(-Vector3.BACK * speed * delta)
const _X = Vector3.RIGHT
const _Y = Vector3.UP
const _Z = Vector3.BACK
extends Spatial
var speed = 1.0
func _physics_process(delta):
self.look_at(Vector3(G.player_place.x,self.translation.y,G.player_place.z), G._Y)
self.translate(-G._Z * speed * delta)
Улучшаем управление
Донастроим управление. Перемещение стрелками хотелось бы дублировать кнопками WASD. Откроем Project — Project Settings… Переключимся на вкладку Input Map.
Находим здесь строчку ui_left. Этот ключ уже используется в нашем управлении персонажем, теперь осталось добавить в него дополнительную кнопку. Нажимаем на плюсик сбоку, выбирая Key.
Появится окошко с предложением нажать кнопку. Нажимаем A и жмём ок.
Внутри группы записей под ui_left появилась новая строчка.
Аналогичным образом добавляем D в ui_right, W в ui_up и S в ui_down.
Добавим также пару своих новых ключей, связанных с кнопками мыши. Для этого в верхней строчке Action пишем имя нового ключа mouse_L и нажимаем справа кнопку Add.
Находим строчку mouse_L, нажимаем на плюс справа и на этот раз выбираем пункт Mouse Button.
Здесь сразу выставлена левая кнопка, поэтому сразу подтверждаем нажав на Add.
Аналогичным образом заведём ключ для правой кнопки мыши — mouse_R.
На этом закрываем окошко, нажав на Close. Если запустить игру сейчас, то персонаж будет управляться и клавиатурными стрелками и кнопками WASD.
Повесим теперь на кнопки мыши, например, операции включения и выключения источника света. Для этого откроем скрипт theMain.gd и допишем сверху, где-нибудь после строки onready var hero_visual = $Hero/Visual строчку onready var light = $Hero/OmniLight — чтобы завести ссылку на источник света.
Теперь сместимся по скрипту ниже и выше function _process() добавим следующие строки:
func _physics_process(delta):
if Input.is_action_pressed("mouse_L"):
light.show()
if Input.is_action_pressed("mouse_R"):
light.hide()
Как видно, в коде сейчас идут друг за другом несколько функций: _unhandled_input, _physics_process и _process. Для простоты можно считать, что они расположены в порядке от самой медленно срабатывающей функции, до самой быстрой. На самом деле всё немного сложнее (первая происходит лишь в моменты ввода, вторая крутится в цикле с фиксированной частотой, третья крутится в цикле с максимальной частотой), но что касается проверок ввода вроде if Input.is_action_pressed — если расположить их в _unhandled_input (хотя она предназначена скорее для обработки events), то скорость срабатывания может быть недостаточной (эффект происходит когда кнопка чётко нажата, с фиксацией на ней). В то время как внутри _physics_process скорость срабатывания скорее всего уже оптимальная, не говоря уже о _process. Проверки разового нажатия (if Input.is_action_just_pressed вместо длительного action_pressed) так и вовсе не сработают внутри _unhandled_input.
Когда нужно сделать, чтобы что-то самостоятельно (без пользовательского ввода) происходило в цикле с меньшей частотой, чем у функции _physics_process (не делая слишком много вычислений в каждом такте), то можно в ней завести таймер, который будет запускать нужные операции через определённые отрезки времени, снижая общую вычислительную нагрузку.
Можно запустить игру и убедиться, что при нажатии на кнопки мыши источник света включается и отключается. Также можно ради интереса временно перетащить проверки ввода из _physics_process в _unhandled_input, для того чтобы выяснить насколько медленнее будут срабатывать кнопки мыши. Кстати, стоит помнить, что отжатие (как и нажатие) какой-то кнопки тоже вызывает срабатывание функции _unhandled_input.
Теперь можно упростить код ранее созданного движения/вращения персонажа, сократив количество проверок и перебросив его в _physics_process. Для этого выполним сначала предварительные действия — выделим всё, что было внутри функции _prоcess и нажмём Ctrl + K, чтобы строки закомментировались и были исключены из выполнения.
Вверху скрипта удалим строчки var horizontal_joy = 0 и var vertikal_joy = 0.
Затем добавляем сюда строчку var move_direction = Vector3.ZERO
Спустимся к функции _unhanlled_input и заменим её код на такой:
func _unhandled_input(event):
if game_mode == 1:
G.player_place = main_hero.global_translation
move_direction.x = Input.get_axis("ui_left","ui_right")
move_direction.z = Input.get_axis("ui_up","ui_down")
print(move_direction)
if move_direction != Vector3.ZERO:
hero_visual.look_at(hero_visual.global_transform.origin + move_direction, G._Y)
Далее переписываем функцию _physics_process таким образом:
func _physics_process(delta):
if game_mode == 1:
if Input.is_action_pressed("mouse_L"):
light.show()
if Input.is_action_pressed("mouse_R"):
light.hide()
if rcast.is_colliding() == false:
if move_direction != Vector3.ZERO:
main_hero.translation += move_direction * hero_speed * delta
else:
HealthDamage(1)
if health < 0:
health = 0
game_mode = 0
G.player_place = Vector3.ZERO
move_direction = Vector3.ZERO
ui_startscreen.show()
Запускаем игру.
В логе теперь выводится общий вектор move_direction, получаемый при вводе от пользователя в _unhandled_input. Если вектор ненулевой (то есть нужные ключи направления были нажаты), то персонаж поворачивается. Во время _physics_process персонаж, пока вектор направления остаётся куда-то повёрнут (то есть пока кнопки движения не отжаты) - движется. Если отжать кнопки движения, то в момент отжатия _unhandled_input вычислит, что move_direction снова стал нулевым вектором и, соответственно, _physics_process перестанет двигать персонажа. Правда следует учитывать, что событие отжатия может остаться необработаным функцией _unhandled_input, если пользователь в процессе куда-то переключился из окна игры - тогда персонаж залипнет в движении, до тех пор пока в контексте не окажется игровое окно и снова не поступит ввод от пользователя.
Теперь закомментированную функцию _process можно удалить, так как пока она не понадобится.
Выстрелы и прочее
Сделаем теперь простенький выстрел. Переключимся на 3D в сцену MainScene. Выделим узел Main и добавим новый узел Area.
Добавим к Area узел CollisionShape, выберем ему форму сферы (New SphereShape) в поле Shape в инспекторе.
Откроем более подробные настройки SphereShape (щёлкнув по cfvjq надписи SphereShape) и поставим радиус 0.5.
Переименуем узел Area в MagicStrike. Сохраним как префаб (правая кнопка, Save Brunch as Scene).
Щёлкнем по папке Prefabs. Создадим внутри неё папку Spawned, куда и сохраним наш префаб MagicStrike.
Заходим внутрь префаба.
Выделим корневой узел и добавим к нему (Ctrl + A) новый узел MeshInstance, чтобы сделать выстрелу какую-то визуальную форму. Заходим в новом узле в его поле Mesh в инспекторе и выбираем New PrismMesh.
В поле Scale поставим размеры 0.3 по всем осям.
Выделим корневой узел (Magic Strike) и нажмём на свиток с зелёным плюсом, чтобы добавить скрипт.
Нажимаем на иконку папки. Поднимаемся пару раз вверх.
Щелкаем два раза на папке Scripts.
Меняем название на theMagicStrike и жмём Open.
Нажимаем Create.
Открылся скрипт выстрела. Меняем его код на следующий:
extends Area
var timer = 0.0
var lifetime = 2.0
func _physics_process(delta):
if timer > -1.0:
timer += delta
if timer > lifetime:
timer = -1.0
queue_free()
Сохраним скрипт. Запустим игру (сохранив тем самым открытые сцены). На точке старта должен появляться белый треугольник, и через пару мгновений пропадать.
Переключимся на 3D, закроем сцену MagicStrike, оказавшись в MainScene.
Удалим теперь сам узел MagicStrike с главной сцены.
Делаем мы это потому, что иметь выстрел изначально в сцене нам не нужно, мы будем создавать его префаб во время игры, из кода.
Заходим в скрипты (Scripts), открываем скрипт theMain. Пишем ниже первой строки новую: var magic_strike = preload("res://Prefabs/Spawned/MagicStrike.tscn")
Таким образом мы предзагрузим префаб-выстрел, чтобы создавать его в процессе игры.
Теперь спускаемся ниже, в функцию physics_process и пописываем в условие if Input.is_action_pressed("mouse_L"): , ниже light.show() новые строчки:
var cloned_pref = magic_strike.instance()
cloned_pref.transform = main_hero.transform
cloned_pref.translation.y = 0.5
self.add_child(cloned_pref)
Запустим игру, видим, что если нажимать правую кнопку мыши, то за персонажем образуется целая "змейка" из треугольных выстрелов. Это происходит потому, что мы поместили создание выстрела в условие if Input.is_action_pressed("mouse_L"), где is_action_pressed означает, что событие происходит всё время, когда нажата кнопка..
Поправим это, дописав выше только что написанного куска кода отдельное условие, которое будет происходить разово, только в момент нажатия кнопки. Вот, как нужно переписать эту часть:
if Input.is_action_just_pressed("mouse_L"):
var cloned_pref = magic_strike.instance()
cloned_pref.transform = main_hero.transform
cloned_pref.translation.y = 0.5
self.add_child(cloned_pref)
Теперь на каждое нажатие левой кнопки мыши создаётся один треугольный выстрел.
Переключимся теперь в 3D в основную сцену. Зайдём в узел уровня (Level_A1).
Выделим узел врага (Enemy_red) и сделаем ещё 4 копии (Ctrl + D).
Теперь растащим их примерно по углам уровня. И одного в центре.
Запустим игру. Теперь врагов стало много.
Разве что они все сжимаются в одного, когда проходит некоторое время и во время перезапуска. Немного подправим этот момент. Открываем скрипты, заходим в theAI_red.gd и напишем временное частичное решение, переписав код таким образом:
extends Spatial
var speed = 1.0
var my_point = Vector3.ZERO
func _ready():
my_point = self.translation
func _physics_process(delta):
self.look_at(Vector3(G.player_place.x,self.translation.y,G.player_place.z), G._Y)
self.translate(-G._Z * speed * delta)
if G.player_place == Vector3.ZERO:
self.translation = my_point
Теперь каждый враг запоминает свою начальную позицию, а также смотрит - не оказался ли внезапно игрок в нулевых координатах (что случается при рестарте уровня). Таким образом слепившихся вместе врагов можно сбросить на их начальные позиции, потеряв всё здоровье от соприкосновения с колоннами.
На этом пока и остановимся.