Предисловие
Костыль – палка с поперечиной, закладываемой под мышку, служащая опорой при ходьбе хромым. Так написано в википедии и достаточно хорошо, описан функционал, описан внешний вид и способ использования. Мне достаточно этой информации для того, чтобы собрать свой собственный костыль!
А что с геймдевом? Безжалостная пустошь, где ты – путешественник, в поисках скрижалей, крупиц информации, в надежде собрать полный свиток. Бродишь по бескрайним просторам интернета, а в некоторых случаях слушаешь байки своих коллег по цеху и с дрожащими руками записываешь все их слова в блокнот.
Такой скрижалью хочу поделиться и я, на примере простой системы инвентаря.
Реализация будет на версии Godot 4.x.x. Будет проще, если есть базовые знания работы c Нодами, Сценами и Синглтонами.
Репозиторий с исходниками вот тут
Пункты
-
Импортируем данные предметов из JSON
-
Инвентарь для хранения предметов игрока
-
Строим UI инвентаря
-
Захватываем input игрока
-
Прикручиваем Pick and Drop с помощью мышки
-
Stack and Split предметов
-
Отображаем всплывающую подсказку на предмете
Импорт данных из JSON
Для хранения данных о предметах я буду использовать формат JSON’a, хранить, модифицировать и переносить его проще. Есть много вариантов, где мы можем хранить информацию о предметах, это может быть словарь или обычный двумерный массив.
{ "orange": { "name": "Апельсин", "description": "Сочный вкусный желтый апельсин!", "icon": "orange_icon.png", "stackable": true, "quantity": 1, }, "potion": { "name": "Зелье здоровья", "description": "Прибавляет +5 к здоровью", "icon": "health_icon.png", "stackable": true, "quantity": 1, }, "sword": { "name": "Меч", "description": "Старый ржавый меч", "icon": "sword_icon.png", "stackable": false, } }
Создаём небольшой список предметов и назовём его items.json, в моём случае это – “orange”, “potion” и “sword”. Главное здесь это:
-
Stackable – True/False если предмет будет stackable
-
Quantity – Количество предметов и использований доступных нам
Теперь у нас есть файл, с перечислением доступных нам предметов, необходимо их перенести для использования и хранения. Для начала создаём скрипт Global.gd и папку scripts , где мы будем хранить наши скрипты.
extends Node var items func _ready(): items = read_from_JSON("res://data/items.json") for key in items.keys(): items[key]["key"] = key func read_from_JSON(path: StringName): var file var data if (FileAccess.file_exists(path)): file = FileAccess.open(path, FileAccess.READ) data = JSON.parse_string(file.get_as_text()) file.close() return data else: printerr("File doesn't exist")
Создаём функцию read_from_JSON
с параметром path
, где определяем путь к нашему JSON файлу. Используем встроенный метод FileAccess для доступа к чтению. Если обработка прошла без ошибок, возвращаем наши спарсенные данные, если файл обрабатывается некорректно, возвращаем ошибку.
В функции _ready
вызываем read_from_JSON
и передаём путь к нашему списку предметов res://data/items.json, сохраняем значения в переменной items
. Внутри цикла мы сохраняем ключ как свойство элемента, поскольку каждый предмет идентифицируется уникальным ключом, для того, чтобы в будущем определить, относятся ли два элемента к одному и тому же типу.
Далее, нам необходимо добавить наш скрипт Global.gd в автозагрузку как синглтон. Для этого переходим в Проект -> Настройки проекта, выбираем пункт Автозагрузка и добавляем наш скрипт Global.gd. Называем его Global и ставим чекбокс Глобальная переменная на включить. Теперь мы имеем доступ к классу Global глобально.
Очень хорошо расписано про паттерны игрового программирования в книге “Шаблоны игрового программирования” Роберта Найстрома.
Должно получится вот так
Добавим ещё одну полезную функцию для получения предмета по ключу. Добавим код в конец файла Global.gd.
func get_item_by_key(key): if items && items.has(key): return items[key].duplicate(true) else: printerr("Item doesn't exist")
Эта функция принимает ключ предмета как параметр и возвращает его дубликат.
Для тестирования, создаём сцену Node2D, переименовываем в Main. Создадим папку scenes для хранения наших сцен. Сохраняем сцену как main.tscn и запускаем проект (F5), выбираем нашу сцену главной.
Теперь добавим вывод нашей функции get_item_by_key
в конец _ready
файла Global.gd для проверки нашего парсера.
print(get_item_by_key("orange"))
Снова запускаем проект (F5) и смотрим в вывод.
Получаем такой вывод
Инвентарь для хранения предметов игрока
В этом разделе мы сделаем инвентарь для хранения предметов игрока. Создадим новый скрипт Inventory.gd, добавим следующий код:
extends Node signal items_changed(indexes) var cols: int = 6 var rows: int = 6 var slots: int = cols * rows var items: Array = [] func _ready(): for i in range(slots): items.append({}) func set_item(index, item): var previos_item = items[index] items[index] = item items_changed.emit([index]) return previos_item func remove_item(index): var previos_item = items[index].duplicate() items[index].clear() items_changed.emit([index]) return previos_item func set_item_quantity(index, amount): items[index].quantity += amount if items[index].quantity <= 0: remove_item(index) else: items_changed.emit([index])
Сначала мы определяем переменные:
-
cols
– Количество столбцов в инвентаре, сейчас 6 -
rows
– Количество строк в инвентаре, сейчас 6 -
slots
– Общее количество ячеек в инвентаре, 6 x 6 = 36 -
items
– Массив в котором будем хранить предметы игрока
Сигнал items_changed
будет вызываться каждый раз, когда мы будем работать с индексами нашего инвентаря. Мы передаём массив индексов вместе с сигналом, указывая, какая ячейка инвентаря изменилась. Этот сигнал нам понадобиться для работы с UI инвентаря в дальнейшем.
Изначально инвентарь будет пуст, в функции _ready
мы заполняем наш массив items
пустым словарём перебирая количество ячеек.
Наш каждый элемент массива items
представляет собой индекс в позиции ячеек инвентаря. Доступ к предмету мы можем получить так: items[0]
Для взаимодействия с инвентарём определим несколько функций:
-
set_item
– Функция принимает два параметра,index
– индекс ячейки инвентаря иitem
– предмет из списка предметов, добавляемый в массив предметов игрока. Функция возвращает предмет, ранее сохранённый в данной ячейке. -
remove_item
– Функция принимает параметрindex
– индекс ячейки инвентаря, удаляя предмет из массива предметов игрока. Так же возвращает ранее сохранённый предмет в данной ячейке. -
set_item_quantity
– Функция принимает два параметра,index
– индекс ячейки инвентаря иamount
– сумму, которую нужно вычесть или прибавить. Если количество предмета будет равняться нулю или меньше, мы удаляем предмет из массива предметов игрока.
По аналогии с прошлым пунктом, нам необходимо добавить наш скрипт Inventory.gd в Автозагрузку как синглтон. Получаем два глобальных класса Global и Inventory.
Получаем такой результат на выходе
Строим UI инвентаря
Теперь мы готовы к разработке интерфейса для нашего инвентаря. Для начала создадим папку interface для хранения наших сцен.
Наш интерфейс будет заполняться через GridContainer, что даст нам большую адаптивность заполнения в зависимости от количества строк и столбцов.
Создадим сцену items_slot.tscn, выберем главный узел ColorRect и назовём его ItemRect. Зададим пресет якоря для нашей сцены на Центр. Установим свойство Transform -> Size по X и Y -> 100px и добавим аналогичные значение в свойство Custom Minimum Size. Теперь наш родительский контейнер центрирован, что лишает нас неожиданных сюрпризов в будущем.
Добавим дочерний узел типа TextureRect с именем ItemIcon. В этом узле будет отображаться иконка нашего предмета. Устанавливаем свойства Transform -> Size по X и Y -> 80px, а Position по X и Y -> 10px. Теперь наша иконка центрирована внутри родительского узла. Так же установим свойство Expand Mode -> Fit Width, для корректного отображения пропорций иконки.
Добавим ещё один узел типа Label с именем LabelQuantity. В этом узле будем отображать количество предметов из свойства quantity
. Задаём пресет якоря на Справа снизу. Так же можем изменить настройки размера шрифта в свойстве Label Settings -> Font -> 18px.
Проставим узлам ItemIcon и LabelQuantity доступ по уникальному имени.
interface/items_slot.tscn
Дальше нам необходимо добавить ItemRect в группу. Для этого переходим в Узел -> Группы и добавляем новую группу items_slot. Это поможет нам в дальнейшем получить все ячейки элементов в массиве.
Теперь добавим скрипт к нашей сцене:
extends ColorRect @onready var item_icon = %ItemIcon @onready var item_quantity = %LabelQuantity func display_item(item): if item: item_icon.texture = load("res://textures/Items/%s" % item.icon) item_quantity.text = str(item.quantity) if item.stackable else "" else: item_icon.texture = null item_quantity.text = ""
Мы получаем ссылку на дочерние узлы с помощью ключевого слова @onready
. Вызов функции display_item
обновляет иконку и количество предмета. Путь к изображениям наших предметов res://textures/Items/[item.icon]
Далее добавим скрипт наследуемый от класса GridContainer, который мы будем использовать в нашем меню инвентаря. Создаём новый ContainerSlot.gd скрипт. Добавляем код:
extends GridContainer class_name ContainerSlot var ItemSlot = load("res://scenes/interface/items_slot.tscn") var slots func display_item_slot(cols: int, rows: int): var item_slot columns = cols slots = cols * rows for index in range(slots): item_slot = ItemSlot.instantiate() add_child(item_slot) item_slot.display_item(Inventory.items[index]) Inventory.items_changed.connect(_on_Inventory_items_changed) func _on_Inventory_items_changed(indexes): var item_slot for index in indexes: if index < slots: item_slot = get_child(index) item_slot.display_item(Inventory.items[index])
Для начала, мы определили class_name
чтобы в будущем обратиться к классу по имени ContainerSlot
. В переменной ItemSlot
передаем экземпляр ранее созданной сцены items_slot.tscn.
В функции display_items_slot
мы передаём, сколько столбцов и строк будет отображено в интерфейсе нашего инвентаря. Цикл создаёт экземпляр сцены и добавляет его в GridContainer. Наконец мы передаём сигнал обновления items_changed
, созданный ранее в синглтоне Inventory, для обновления индекса ячейки предмета в инвентаре.
Добавим еще один маленький срипт (точно последний на этом этапе) InventoryMenu.gd. Он уже послужит нам функцией вывода отображения наших ячеек.
extends ContainerSlot func _ready(): display_item_slot(Inventory.cols, Inventory.rows) position = (get_viewport_rect().size - size) / 2
Наследуемся от класса ContainerSlot и вызываем функцию display_item_slots
для отображения ячеек.
Последним этапом, нам необходима сцена, куда мы будем выводить наши ячейки инвентаря. Создаем новую сцену inventory.tscn, добавляем родительский узел Control с названием Inventory. Устанавливаем пресет якоря на Полный прямоугольник. Затем добавим узел PanelContainer с названием InventoryContainer. Это наш контейнер, в который мы будем добавлять все остальные будущие обновления для нашего инвентаря. Установим пресет якоря на Полный прямоугольник. Последним добавляем узел GridContainer с названием InventoryMenu. Устанавливаем горизонтальное и вертикальное выравнивание в статус Прижать к центру. Добавляем скрипт InventoryMenu.gd к узлу InventoryMenu. Наша сцена должна выглядеть вот так:
interface/inventory.tscn
На этом мы закончили настройку отображения нашего инвентаря!
Захватываем input игрока
Отлично, мы настроили инвентарь для хранения предметов и его отображение, теперь необходимо все это перенести в главную сцену. Переходим в ранее созданную сцену Main.tscn и добавляем узел CanvasLayer, переименовываем в UI. Далее добавляем нашу сцену Inventory.tscn и активируем возможность редактирование потомков сцены. CanvasLayer позволяет нам рендерить элементы интерфейса поверх игры. Итог нашей сцены:
interface/main.tscn
Далее нам нужно привязать кнопку открытия и закрытия нашего инвентаря. Переходим в Проект -> Настройки проекта -> Список действий. Добавляем действие ui_inventory
и привязываем кнопку Tab к нашему действию. Получаем такой результат:
Добавим скрипт InventoryHandler.gd к нашей Inventory сцене:
extends Control func _unhandled_input(event): if event.is_action_pressed("ui_inventory"): visible = !visible
Мы используем функцию _unhandled_input
, которая будет вызываться при действии ввода. Ловим момент нашего действия ui_inventory
и меня свойство visible инвентаря.
Проверим работу, запустим проект (F5) и убедимся, что наш инвентарь открывается и закрывается.
Pick and Drop предметов
Следующей фишкой нашего инвентаря станет возможность перемещать и выбирать предметы с помощью мышки.
Мы сделаем новую сцену DragPreview. Эта сцена будет отвечать за предварительный просмотр перетаскиваемых предметов. Нам нужно будет обновлять её позицию каждый кадр для следования за мышью.
Создаём новую сцену drag_preview.tscn с корневым узлом Control и именем DragPreview. Устанавливаем свойство Mouse -> Filter на Ignore. Добавляем дочерний узел Control с именем Item. Устанавливаем свойство transform на X и Y -> 140px.
Дальше нам нужно добавить вывод иконки и количества нашего предмета. Добавляем узел TextureRect с именем ItemIcon дочерним от узла Item. Устанавливаем свойство transform на X и Y -> 120px, ставим пресет якоря на Центр. Не забываем про свойство Expand Mode -> Fit Width. Добавим узел Label с именем LabelQuantity. Установим в свойстве Label Settings -> Font -> Size на 18px и Horizontal Alignment -> Right. Якорь пресета установим на Справа снизу. Сохраняем сцену и получаем вот такой результат:
interface/drag_preview.tscn
Теперь добавим скрипт DragPreview.gd к нашей сцене:
extends Control @onready var item_icon = $Item/ItemIcon @onready var item_quantity = $Item/LabelQuantity var dragged_item = {} : set = set_dragged_item func _process(delta): if dragged_item: position = get_global_mouse_position() func set_dragged_item(item): dragged_item = item if dragged_item: item_icon.texture = load("res://textures/Items/%s" % dragged_item.icon) item_quantity.text = str(dragged_item.quantity) if dragged_item.stackable else "" else: item_icon.texture = null item_quantity.text = ""
Здесь мы используем переменную dragged_item
для хранения перетаскиваемого предмета. Изначально он пуст. Так же устанавливаем сеттер на функцию set_dragged_item
. Он будет вызываться каждый раз при изменении перетаскиваемого предмета, обновляя иконку предмета и его количество.
В функции _process
мы двигаем узел за мышью, если перетаскиваемый предмет не пуст.
Мы закончили настройку нашей сцены для предварительного просмотра перетаскиваемых предметов. Теперь добавим экземпляр сцены drag_preview.tscn в нашу сцену инвентаря Inventory.tscn. Должно получиться вот так:
interface/inventory.tscn
Добавим код в наш скрипт InventoryHandler.gd :
@onready var drag_preview = $InventoryContainer/DragPreview func _ready(): for item_slot in get_tree().get_nodes_in_group("items_slot"): var index = item_slot.get_index() item_slot.connect("gui_input", _on_ItemSlot_gui_input.bind(index))
Для начала подключим сигнал gui_input
к каждому слоту инвентаря. Мы можем использовать метод get_tree().get_nodes_in_group()
, где передаём имя искомой группы items_slot
, которую указали ранее. Внутри цикла мы подключаем сигнал gui_input
к каждому слоту из искомой группы, который будет вызывать функцию on_ItemSlot_gui_input
каждый раз, когда узел получит событие ввода. В него передаём index
нашего слота и событие ввода.
Определим нашу функцию _on_ItemSlot_gui_input
:
func _on_ItemSlot_gui_input(event, index): if event is InputEventMouseButton: if event.button_index == MOUSE_BUTTON_LEFT && event.pressed: if visible: drag_item(index)
Если игрок нажмёт левой кнопкой мыши по слоту, мы вызываем функцию drag_item
:
func drag_item(index): var inventory_item = Inventory.items[index] var dragged_item = drag_preview.dragged_item # Взять предмет if inventory_item && !dragged_item: drag_preview.dragged_item = Inventory.remove_item(index) # Бросить предмет if !inventory_item && dragged_item: drag_preview.dragged_item = Inventory.set_item(index, dragged_item) # Свапнуть предмет if inventory_item and dragged_item: drag_preview.dragged_item = Inventory.set_item(index, dragged_item)
Последним, немного модифицируем действие открытия и закрытия инвентаря в функции _unhandled_input
:
func _unhandled_input(event): if event.is_action_pressed("ui_inventory"): # Не даем закрыть инвентарь пока взят предмет if visible && drag_preview.dragged_item: return visible = !visible
Для теста добавим предметы в инвентарь. Перейдём в скрипт Inventory.gd и добавим несколько предметов в конец функции _ready
:
items[0] = Global.get_item_by_key("orange") items[1] = Global.get_item_by_key("orange") items[2] = Global.get_item_by_key("orange") items[3] = Global.get_item_by_key("potion") items[4] = Global.get_item_by_key("potion") items[5] = Global.get_item_by_key("sword")
Запустим проект (F5) и убедимся что наш инвентарь открывается и закрывается, а так же доступен выбор предмета и его перенос в другой слот:
main.tscn
Stack and Split предметов
Теперь мы можем перемещать предметы в меню инвентаря, но остаётся ещё две проблемы. Первая, что одни и те же предметы не складываются вместе, а вторая, что нет возможности их разделить. Самое время это решить!
Откроем наш скрипт InventoryHandler.gd и добавим следующий код:
func drag_item(index): var inventory_item = Inventory.items[index] var dragged_item = drag_preview.dragged_item # Взять предмет if inventory_item && !dragged_item: drag_preview.dragged_item = Inventory.remove_item(index) # Бросить предмет if !inventory_item && dragged_item: drag_preview.dragged_item = Inventory.set_item(index, dragged_item) if inventory_item && dragged_item: # Стакнуть предмет if inventory_item.key == dragged_item.key && inventory_item.stackable: Inventory.set_item_quantity(index, dragged_item.quantity) drag_preview.dragged_item = {} # Свапнуть предмет else: drag_preview.dragged_item = Inventory.set_item(index, dragged_item)
Добавляем на 12 строке новое условие проверки, здесь мы проверяем условие по нашему уникальному ключу key
. Если предметы совпадают и являются stackable
, мы увеличиваем их количество, в ином случае, просто свопаем.
Теперь добавим сплит предметов. Для этого обновляем функцию _on_ItemSlot_gui_input
в том же скрипте, добавив новое условие:
func _on_ItemSlot_gui_input(event, index): if event is InputEventMouseButton: if event.button_index == MOUSE_BUTTON_LEFT && event.pressed: drag_item(index) if event.button_index == MOUSE_BUTTON_RIGHT && event.pressed: split_item(index)
На 5 строке мы добавили новое условие, если игрок нажмёт правую кнопку мыши на выделенном предмете, мы вызываем функцию split_item
.
func split_item(index): var inventory_item = Inventory.items[index] var dragged_item = drag_preview.dragged_item var split_amount var item # Проверяем если предмет стакабл if !inventory_item || !inventory_item.stackable: return split_amount = ceil(inventory_item.quantity / 2.0) if dragged_item && inventory_item.key == dragged_item.key: drag_preview.dragged_item.quantity += split_amount Inventory.set_item_quantity(index, -split_amount) if !dragged_item: item = inventory_item.duplicate() item.quantity = split_amount drag_preview.dragged_item = item Inventory.set_item_quantity(index, -split_amount)
Пройдёмся по нашей функции. Мы получаем индекс предмета инвентаря и перетаскиваемого предмета. Выходим из функции, если в слоте нет предмета или он не складываемый.
Затем мы получаем split_amount
, уменьшив количество предметов вдвое.
На строках 12-14, если предмет перетаскиваемый и имеет такой же ключ, что и ключ предмета в инвентаре, мы увеличиваем его количество на split_amount
, а из предмета в инвентаре вычитаем.
На строках 15-19, если перетаскиваемого предмета еще нет, мы добавим предмет того же типа и изменим его количество, чтобы он имел такую же сумму как в split_amount
. Наконец, мы вычитаем эту сумму из предмета в инвентаре.
Запустим проект и убедимся в том, что наше разделение и сложение предметов работает корректно.
Всплывающая подсказка
В качестве последнего штриха, мы добавим к нашему инвентарю всплывающую подсказку. Она будет отображать дополнительную информацию о нашем предмете, такую как название и описание предмета.
Создаём новую сцену inventory_tooltip.tscn, задаём главный узел ColorRect и переименовываем в InventoryTooltip. Добавим немного прозрачности нашему узлу задав свойство Color -> #313131cb. Пресет якоря назначаем на Слева вверху. Для свойства Custom Minimum Size установим размер по X -> 300px, по Y -> 100px. Так же нам необходимо установить горизонтальное и вертикальное выравнивание для дочерних узлов. В свойстве Container Sizing для Horizontal и Vertical ставим значение Прижать к началу. Не забываем установить свойство для Mouse -> Ignore.
Добавим дочерний узел MarginContainer. Установим пресет якоря на Полный прямоугольник. Свойство Mouse -> Ignore. Установим небольшие отступы для нашего контейнера, для этого переходим в свойства Theme Overrides -> Constants, Margin Left -> 20, Margin Top и Margin Bottom -> 2, Margin Right -> 4.
Создаём 2 узла Label с именем NameLabel и DescriptionLabel, задаём вертикальное выравнивание для NameLabel на Прижать к началу, для DescriptionLabel Прижать к центру. На выходе получаем вот такую сцену:
interface/inventory_tooltip.tscn
Создадим новый скрипт Tooltip.gd для нашей сцены inventory_tooltip.tscn:
extends ColorRect @onready var name_label = $MarginContainer/NameLabel @onready var description_label = $MarginContainer/DescriptionLabel func _process(delta): position = get_global_mouse_position() + Vector2.ONE * 4 func display_info(item): name_label.text = item.name description_label.text = item.description
Мы обновляем позицию нашей подсказки каждый кадр, чтобы она следовала за позицией нашей мышки. В функции display_info
передаем предмет, где будем отображать информацию о нашем предмете по ключам name
и description
.
Теперь добавим экземпляр сцены inventory_tooltip.tscn в нашу сцену с инвентарём Inventory.tscn. Так как по умолчанию подсказка должна быть скрыта, скроем её, нажав на значок глаза. Наша сцена должна выглядеть вот так:
interface/Inventory.tscn
Переходим в наш скрипт Inventory.gd и добавляем сцену с подсказкой:
@onready var tooltip = $InventoryContainer/InventoryTooltip
Для показа и скрытия подсказки, добавим сигналы mouse_entered
и mouse_exited
в функцию _ready
.
func _ready(): for item_slot in get_tree().get_nodes_in_group("items_slot"): var index = item_slot.get_index() item_slot.connect("gui_input", _on_ItemSlot_gui_input.bind(index)) item_slot.connect("mouse_entered", show_tooltip.bind(index)) item_slot.connect("mouse_exited", hide_tooltip)
Определим функции show_tooltip
и hide_tooltip
.
func show_tooltip(index): var inventory_item = Inventory.items[index] if inventory_item && !drag_preview.dragged_item: tooltip.display_info(inventory_item) tooltip.visible = true else: tooltip.visible = false func hide_tooltip(): tooltip.visible = false
Отображать подсказку будем только в том случае, когда есть предмет в слоте инвентаря и он не в моменте перетаскивания.
Под конец добавим скрытие подсказки при переключении отображения инвентаря и в момент перетаскивания предмета.
func _on_ItemSlot_gui_input(event, index): if event is InputEventMouseButton: if event.button_index == MOUSE_BUTTON_LEFT && event.pressed: hide_tooltip() drag_item(index) if event.button_index == MOUSE_BUTTON_RIGHT && event.pressed: hide_tooltip() split_item(index) func _unhandled_input(event): if event.is_action_pressed("ui_inventory"): # Не даем закрыть инвентарь пока взят предмет if visible && drag_preview.dragged_item: return visible = !visible hide_tooltip()
Здесь добавили вызовы функции hide_tooltip
. Запускаем проект и тестируем наш супер навороченный инвентарь!
Финал
Надеюсь этот туториал (или записки сумасшедшего) помогли вам на пути к реализации той самой игры! Спасибо за уделённое время!
P.S. Вдохновение взято отсюда (сделано на Godot 3.x.x.)