Анатомия инвентаря в Godot

Один из способов создания инвентаря, на примере движка Godot.

Анатомия инвентаря в Godot

Для начала пару слов об общей архитектуре

Сначала посмотрим общим взглядом на структуру. Основной скрипт (Main) главной сцены у меня отвечает за хранение ссылок к большинству объектов, за прокрутку игрового цикла, отслеживание части нажатий кнопок, выставление стартовых параметров, манипулирование игровыми окнами и прочие вспомогательные вещи. Есть один скрипт ещё более верхнего уровня, глобальный (G), отвечающий за хранение переменных, которые потребуются многим отдельным скриптам, через него же скрипты могут обращаться к основному. Также в нём хранятся разные вспомогательные методы, которые вынесены отдельно из некоторых прочих скриптов в одно место, где их проще найти.

<i>Main крутит основной цикл и имеет связи с большинством фиксированных объектов. G обеспечивает доступ к себе из любого узла, а также позволяет любым узлам обращаться к самому Main. </i><br />
Main крутит основной цикл и имеет связи с большинством фиксированных объектов. G обеспечивает доступ к себе из любого узла, а также позволяет любым узлам обращаться к самому Main.

Кнопки инвентаря собраны на отдельном 2д-слое и каждая кнопка представляет собой префаб ячейки инвентаря - отдельную упакованную сцену. Так как привязывать каждую кнопку сигналом к основному скрипту сцены довольно неудобно (равно как и заносить их все в поле экспорта), плюс потребуется не только принимать от них сигналы, но и иметь с ними обратную связь, то я просто завожу массив ссылок на кнопки инвентаря в глобальном скрипте и при первом появлении на сцене они сами себя туда заносят.

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

Каждой кнопке назначается свой ID, а сигналы нажатия они отправляют по сути сами себе. Во время перетаскивания предметов в глобальный скрипт заносятся параметры претаскиваемого объекта, ID ячейки откуда началось перетаскивание и так далее. Затем, если предмет был помещён в новую ячейку, то по этому ID новой ячейки вызывается метод в ячейке назначения, а по старому ID - метод в ячейке отправки. Эти методы меняют параметры ячейки и перерисовывают её визуал, в соответствии с этими новыми параметрами.

Теперь более подробно посмотрим на организацию узлов

Препарировать я тут в основном буду проект прототипа arpg da~Mage:

Итак, основной узел инвентаря - Inventory, прикреплён к древу иерархии главной сцены. Якорем для него выбрана точка справа внизу экрана (то есть при изменении размеров окна, узел Inventory вместе со своим содержимым будет всё время сохранять своё положение относительно правой нижней точки), а в разделе Mouse фильтр установлен в положение Ignore - таким образом вхождение мыши в область узла не будет регистрироваться и не перекроет какие-то прочие элементы.

<i>Слева, в иерархии узел Inventory. На основном экране отмечена салатовым крестиком якорная точка и квадратик области узла. Справа, в поле Mouse-Filter выставлен режим Ignore для того, чтобы область не регистрировала мышь.</i><br />
Слева, в иерархии узел Inventory. На основном экране отмечена салатовым крестиком якорная точка и квадратик области узла. Справа, в поле Mouse-Filter выставлен режим Ignore для того, чтобы область не регистрировала мышь.

Сама по себе область узла есть у всех Control, но часто она не потребуется, поэтому просто оставляем её предустановленным квадратиком (нет нужды его растягивать, чтобы все прочие внутренние элементы в него входили - они будут работать и без этого) и, желательно, не забыть перевести её фильтр в Ignore. А вот у самого первого, корневого узла Control, к которому привязаны прочие 2д-объекты и слои - область узла, как правило, включает в себя весь экран полностью. И, кстати, если ей не включить режим Mouse фильтра в Ignore, то нажатия мышью на объекты 3д-сцены не будут фиксироваться, так как 2д-слой интерфейса будет их перехватывать. Не во всех играх есть реакция 3д объектов на мышь, но стоит помнить о том, что области двухмерных узлов могут перекрывать регистрацию нажатий мыши друг для друга или для 3д-пространства.

<i>Корневой узел Interface и его область - весь экран. Выставляется в выпадающем списке Layout, вариант - Full Rect. </i><br />
Корневой узел Interface и его область - весь экран. Выставляется в выпадающем списке Layout, вариант - Full Rect.

Иногда требуется наоборот, временно заблокировать нажатия мыши на каких-то слоях. Например, для невозможности прожимать какие-то игровые кнопки во время паузы или открытия экрана настроек. Тогда как раз можно заслонить те слои другим узлом Control, с областью в весь экран или его часть, оставив в фильтре для Mouse значение Stop (или Pass).

Мышь регистрируют не только области пустых узлов Control, но и прочие объекты - например цветные прямоугольники Color Rect, кнопки и так далее. Поэтому для них тоже может потребоваться настроить фильтр, чтобы они что-то не заслоняли (здесь ещё всё зависит от специфики узла, например, кнопка с включенным фильтром Ignore просто сама перестанет нажиматься).

Как устроена каждая отдельная ячейка внутри

По сути, в Godot можно обойтись кнопкой и спрайтом, для создания отдельной ячейки. Добавляя какие-то вариации. В данном случае я сделал сцену не из самой кнопки, а из пустышки Control, в которую уже вложена кнопка и прочее. Это может пригодиться, если, например, потребуется добавить какой то фон за кнопкой, а не только перед ней. Или использовать самописную "кнопку" вместо предлагаемой стандартной. Сигнал нажатия кнопки и управляющий скрипт тоже повешены на пустышку.

<i>Область корневого узла ItemSlot не взаимодействует с мышью (на всякий случай, хотя кнопка почти целиком перекрывает область) и показывает дефолтный квадрат 40 на 40. В поле  ID значение по умолчанию -1, а конкретные ID проставлены уже на основной сцене.</i><br />
Область корневого узла ItemSlot не взаимодействует с мышью (на всякий случай, хотя кнопка почти целиком перекрывает область) и показывает дефолтный квадрат 40 на 40. В поле  ID значение по умолчанию -1, а конкретные ID проставлены уже на основной сцене.

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

<i>Отдельные книжки хранятся в общем изображении-атласе. В спрайте мы указываем на сколько колонок и столбцов делить полученное общее изображение и затем, меняя фрейм спрайта, выводим в нём изображение той или иной книги.</i><br />
Отдельные книжки хранятся в общем изображении-атласе. В спрайте мы указываем на сколько колонок и столбцов делить полученное общее изображение и затем, меняя фрейм спрайта, выводим в нём изображение той или иной книги.

Код ячейки

Рассмотрим код узла ItemSlot написанный на GDScript в Godot 3x. Первая строка - extends Control - означает, что скрипт оперирует узлом Control, и, соответственно, имеет доступ к различным встроенным свойствам этого типа узлов. Допустим, если бы это был код для узла Button, то там мы могли бы оперировать какими-то специфическими свойствами типа узлов Button, которых нет у узлов Control.

<i>Также мы выводим наружу параметр ID (export var), чтобы менять его в редакторе. Получаем ссылки (onready var) на спрайт фона и предмета. В 4-й версии движка перед export и onready нужно дописывать @</i><br />
Также мы выводим наружу параметр ID (export var), чтобы менять его в редакторе. Получаем ссылки (onready var) на спрайт фона и предмета. В 4-й версии движка перед export и onready нужно дописывать @

Ячейка хранит в себе параметры книги, которая лежит внутри. book = [0,0,0,0,0]. Можно было хранить одну цифру (номер картинки в атласе), но конкретно в этом прототипе каждая книжка - это набор параметров, а также 0 в первой цифре означает отсутствие предмета. В атласе картинок предметов, соответственно, нулевой фрейм полностью прозрачный, то есть когда мы устанавливаем предмет [0,....] в ячейку, то выводится прозрачное ничто в качестве предмета (как вариант, в таком случае спрайт объекта можно было скрывать из видимости). Фрейм под номером 1, следующий после нулевого, тоже зарезервирован - там содержится условная картинка-маркер, появляющаяся на той ячейке откуда предмет взят.

<i>На слое основной сцены выставляем уникальный ID каждому ItemSlot (желательно и названия им изменить под ID) - то есть первому, ItemSlot_0, пишем 0 в поле ID вместо дефолтной -1. ItemSlot_1 - ID 1 и так далее.</i><br />
На слое основной сцены выставляем уникальный ID каждому ItemSlot (желательно и названия им изменить под ID) - то есть первому, ItemSlot_0, пишем 0 в поле ID вместо дефолтной -1. ItemSlot_1 - ID 1 и так далее.
<i>При появлении на сцене, собственно, ячейка заносит ссылку на себя (self) в специальный массив (books_arr) глобального скрипта (G)</i><br />
При появлении на сцене, собственно, ячейка заносит ссылку на себя (self) в специальный массив (books_arr) глобального скрипта (G)
<i>Кстати, попутно заглянем в <b>главный скрипт</b>, чтобы увидеть массив books_arr, который изначально забит null значениями. Следует помнить, сколько значений мы завели, чтобы назначать ID верно - в данном случае в books_arr содержится 20 элементов, с 0 по 19.</i><br />
Кстати, попутно заглянем в главный скрипт, чтобы увидеть массив books_arr, который изначально забит null значениями. Следует помнить, сколько значений мы завели, чтобы назначать ID верно - в данном случае в books_arr содержится 20 элементов, с 0 по 19.

Далее у нас идёт основная функция от кнопки - сигнал, посылаемый в момент начала нажатия на кнопку (_on_Button_button_down). Если в этой ячейке первая цифра в book больше 1 (то есть 2 и выше), значит, там был предмет и мы начинаем перемещать его (как раз временно заменив картинку в текущей ячейке на фрейм 1: PicBook.frame = 1). Это происходит, если никакой предмет в данное время ещё не зацеплен (showdrag == false), в противном случае возвращаем книгу на место (returnBook()).

<i>Если же книга оказалась нулевой (book[0] = 0) и при этом какая-то книга сейчас зацеплена, то тоже вызываем метод возвращения книги.</i><br />
Если же книга оказалась нулевой (book[0] = 0) и при этом какая-то книга сейчас зацеплена, то тоже вызываем метод возвращения книги.

А вот и сама функция возвращения, описанная ниже в скрипте: сначала мы копируем параметры книги из текущей ячейки (добавляя к ней duplicate(true), чтобы передать именно отдельную, несвязанную копию массива данных). Далее, если books_arr от запомненного ID стартовой ячейки существует, то вызываем метод _setBook() в ней, с параметрами своей книги, а у себя вызываем тот же _setBook(), но с параметрами книги, которую взяли ранее из стартовой ячейки.

<i>В конце происходит ещё и вызов функции updateBooksAura() в глобальном скрипте, чтобы перерассчитать какие-то прочие характеристики, связанные с книгами в определённых ячейках. </i><br />
В конце происходит ещё и вызов функции updateBooksAura() в глобальном скрипте, чтобы перерассчитать какие-то прочие характеристики, связанные с книгами в определённых ячейках.
<i>Если заглянуть в <b>главный скрипт</b>, то видно, что рассчитывается в этом конкретном прототипе - сначала картинки скиллов скрываются, а далее, если в нужных  слотах были книги (результат умножения пары слотов отличен от нуля), то скиллы А и/или Б показываются.</i><br />
Если заглянуть в главный скрипт, то видно, что рассчитывается в этом конкретном прототипе - сначала картинки скиллов скрываются, а далее, если в нужных  слотах были книги (результат умножения пары слотов отличен от нуля), то скиллы А и/или Б показываются.

Осталось разобрать последний простенький метод, который находится в скрипте ячейки: _setBook(). Здесь всё просто, кнопка обращается к себе же по своему ID (такая форма записи осталась от прошлого варианта реализации, можно было писать и просто book = ), выставляя новые параметры для book. А фрейм картинки книги меняется на первую цифру принимаемого массива параметров (book_param[0]).

<i>Так как в принимаемом массиве book_param подразумевается 5 элементов, то обращаться (проверять, менять) к каждому из них конкретно можно через book_param[0], book_param[1],...,[book_param[4]]</i><br />
Так как в принимаемом массиве book_param подразумевается 5 элементов, то обращаться (проверять, менять) к каждому из них конкретно можно через book_param[0], book_param[1],...,[book_param[4]]

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

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

<i>В данном случае у всех книжек прозрачный фон, но в каких-то играх изображения объектов могут быть прямоугольными, непрозрачными, и скрывать собой всю ячейку.</i><br />
В данном случае у всех книжек прозрачный фон, но в каких-то играх изображения объектов могут быть прямоугольными, непрозрачными, и скрывать собой всю ячейку.

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

<i>В цикле проверяется, активен ли флаг showdrag, который выставляется в true при перетаскивании (тогда же устанавливается и изображение перетаскиваемой картинки, и сама она становится видимой).</i><br />
В цикле проверяется, активен ли флаг showdrag, который выставляется в true при перетаскивании (тогда же устанавливается и изображение перетаскиваемой картинки, и сама она становится видимой).
<i>Сама функция таскания за мышью проверяет соответствие нового положения мыши старому (переменная old_mousepos заранее объявлена в шапке скрипта как var old_mousepos = Vector2.ZERO, а new_mousepos вычисляется в моменте), и двигает узел, когда те не совпали.</i><br />
Сама функция таскания за мышью проверяет соответствие нового положения мыши старому (переменная old_mousepos заранее объявлена в шапке скрипта как var old_mousepos = Vector2.ZERO, а new_mousepos вычисляется в моменте), и двигает узел, когда те не совпали.

Отдельно замечу, что в 4-й версии движка в коде мало что меняется конкретно при работе с логикой интерфейса. Тем не менее некоторые отличия есть: кроме вышеупомянутых @export и @onready, вместо просто export и onready в 4-ке понадобится, например, в последнем фрагменте кода писать не drag_book.rect_position, а drag_book.position (так как ключевое слово для этого свойства изменили, для большего единообразия и удобства).

Примеры

В моём проекте da~Mage инвентарь пока чуть более простой и однородный - то есть, ячейки одинаковые, в каждой может лежать любой объект. В прочих проектах инвентарь посложнее, и там присутствовали ещё и слоты экипировки, как отдельные уникальные префабы ячеек, в которые можно помещать лишь объекты какого-то одного типа (оружие в слот оружия, кольцо в слот кольца и так далее). Здесь пока не реализован подбор предметов, а также их утилизация или выбрасывание. Скорее всего тут не будет прочих предметов, кроме книг, а разные таскаемые бутылки упразднены и заменены подбираемыми бонусами.

da~Mage, arpg с планируемыми непродолжительными забегами по локациям на выживание (и возможное перерождение в найденных телах) и упрощённым инвентарём, где скиллы создаются комбинациями предметов.

В диаблоиде Панделирий интерфейс более комплексный - помимо общих ячеек инвентаря есть отдельные слоты экипировки, предметы можно подбирать, ломать и выбрасывать, при наведении на предмет высвечивается подсказка. Само перетаскивание тут сделано так, чтобы требовалось зажимать предмет, чтобы тащить куда-то далеко (в отличии от da~Mage, где нужно именно кликать в точку старта и финиша), иначе он падает в первый свободный слот. Здесь тоже в качестве параметров предмета передаётся массив значений, а не одно лишь число картинки, так как некоторые местные предметы имеют заряды, а другие - прогресс изучения. К тому же, что привычно в диаблоидах, предметы могут иметь одинаковую картинку, но различаться по редкости/названию. Одним словом тут напрашивается подход к предмету как к составному конструктору, где одна картинка может обозначать разное, а сам предмет может складываться из нескольких слоёв кратинок.

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

В Сферамиде принцип во многом похож на реализацию интерфейса Pandelirium, но в качестве параметра предмета передаётся всего одно число (фрейм конкретной картинки). Здесь, как и в da~Mage, заклинания зависят от комбинаций предметов - нужно комбинировать особые камни, помещая их в специальные слоты камней. Кроме того тут реализован "органический инвентарь", он же более гибкая версия стандартного диабло-инвентаря, где предметы занимали определённое место - здесь различная экипировка имеет определённую форму влияния на окружающие ячейки, поэтому стоит располагать её в определённом порядке, для более компактного укладывания.

Spheramyd, простой сферический околодиаблоид, с "органическим инвентарём", слотами экипировки и камнями для комбинаций. По концепции всё-таки ориентирован не на полноценную прокачку персонажа, а на краткие забеги.

В космическом аркадном Outsiders, написанном уже в Godot 4, "инвентарь" состоит из ячеек двух видов, которые несовместимы друг с другом - это ячейки для героев (экипаж), и ячейки для грузов (различные объекты, артефакты, товары, заряды, оружие). В плане кода ячейки универсальны, но у ячеек ответственных за предметы ID начинаются с 1000 (чтобы однозначно не пересекаться по ID с ячейками героев, количество которых не должно превысить пары десятков), в то время как у героев от 0 - поэтому, в зависимости от ID, ячейки по разному реагируют на перетаскивание (объекты можно перемещать только в слоты того же типа).

Так как массив элементов должен "честно" состоять из 1000+ элементов, чтобы простым образом обращаться к его 1000+ элементам, ссылки на ячейки героев хранятся в одном массиве, а ссылки на ячейки предметов - в другом, и когда они обращаются к прочим ячейкам этого вида по параметру ID, то отнимают от него 1000, чтобы получить правильный номер ячейки в массиве. То есть мы не заводим массив на все эти гипотетические 1000+ элементов, а имеем два отдельных небольших массива.

Outsiders - космическое ролевое аркадное приключение, где звездолётики разного типа возят героев по планетам и миссиям, получая способности от связи с различными героями.

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

5050
18 комментариев

За статью спасибо.

2

Не за что.

1

Чертов дтф, увидел такую статью случайно, и то через гугл ленту

1

Ну, так тут поставлена работа площадки

2

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

UPD как я понял это gdscript, тогда ещё легче, вроде там массивы динамические и с ними можно работать как с List

Я специально завожу многие вещи явным образом, чтобы не запутаться где у меня что. К тому же языки могут быть разные, а проекты иногда приходится откладывать, чтобы вернуться потом - так легче вспомнить и сориентироваться.

DTF - вы с ума сошли? Редактирование комментария теперь по подписке?