Одной лишь мышкой
Кнопки, всё о чем вы хотели, но боялись спросить
Всем привет, сразу к делу, а почему бы нам сделать инвентарь с Drag&Drop`ом и бонусом от меня?
Начнём) Я не дизайнер, поэтому будет функциональный вариант, задизайните потом сами)
Сначала создам проект и накидаю необходимые для работы ноды в минимальном варианте
В контрол кидаем PanelContainer, его через кнопку Layout(Вид) растягиваем по всему контролу и сразу накидываем флаги на расширение по высоте и ширине
Чилдом кидаем ГридКонтейнер(сетка), в неё мы уже будем кидать наши элементы, так же для удобства отладки добавим кнопку “поднятия” предмета, она будет генерировать рандомный элемент с рандомным кол-вом.
У нас будет 8 столбцов в инвентаре и 4 строчки, для необходимого разнообразия подготовил иконки итемов.
Скачаем с гугла шрифт и закинем его в контрол, чтобы мы могли менять размер шрифта
Далее чуть стилизуем, чтобы это больше было похоже на инвентарь, создаём один слот, и сохраняем его в отдельный файл, т.к. мы его будем динамически создавать слоты
Далее закидываем в главную сцену следующий скрипт:
extends Control export (int, 1, 20) var columns = 8 export (int, 1, 20) var rows = 4 onready var inv = $InvContainer/InvContent const slot_scene = preload("res://Slot.tscn") func _ready(): inv.columns = columns for i in range(columns*rows): var slot = slot_scene.instance() inv.add_child(slot)
Промежуточный вариант примерно такой
Открываем сцену слота, добавляем туда ещё одну панель, добавляем ей пустой стиль, в неё TextureRect для иконки и Label для кол-ва элементов
Ставим для Иконки такие параметры, если кому интересно, напишите в комментариях, я подробнее расскажу про все параметры, которые использовал в статье
Для текста похожие параметры:
в Slot создаём скрипт, и кидаем тестовый код
extends PanelContainer onready var item = $Item onready var icon = $Item/Icon onready var count = $Item/Count var item_type = null var item_count = 0 func _ready(): update_data({"type": "item_type_1", "count": 0}) func update_data(data = null): item.visible = data != null if data: icon.texture = load("res://graphics/%s.png" % data.type) #Динамическая загрузка иконки count.text = str(data.count)
Получаем такую картину:
Теперь займёмся кнопкой очистки:
Изменяем главный скрипт:
extends Control export (int, 1, 20) var columns = 8 export (int, 1, 20) var rows = 4 onready var inv = $InvContainer/InvContent const slot_scene = preload("res://Slot.tscn") func _ready(): $InvContainer/HBoxContainer/Clear.connect("pressed", self, "clear_inventory") inv.columns = columns for i in range(columns*rows): var slot = slot_scene.instance() inv.add_child(slot) func clear_inventory(): for child in inv.get_children(): #Пробегаем по чилдам инвентаря child.update_data() #делаем апдейт без параметров
Очистка очень простая, коннектимся к сигналу кнопки и функцией из цикла с одной строчкой очищаем инвентарь.
Далее кнопка рандомного добавления:
Для начала в скрипт изменим так:
extends PanelContainer onready var item = $Item onready var icon = $Item/Icon onready var count = $Item/Count var item_data = null func _ready(): update_data()#{"type": "item_type_1", "count": 123}) func empty(): return item_data == null func update_data(data = null): item.visible = data != null item_data = data if item: icon.texture = load("res://graphics/%s.png" % item_data.type) #Динамическая загрузка иконки count.text = str(item_data.count) return true
Закидываем в главный скрипт новые функции:
func has_empty_slot(): #Метод проверки наличия хотя бы одной пустой ячеки for child in inv.get_children(): #Пробегаем по чилдам инвентаря if child.empty(): return true return false func get_empty_slot(): #Метод получения случайной пустой ячеки var slot = null if has_empty_slot(): #Обязательно нужно проверить, что у нас есть пустые ячейки #Иначе при полном инвентаре будет бесконечный цикл при полном инвентаре и игра зависнет while slot == null: #Ищем случайную пустую ячейку, пока не найдём var temp_slot = inv.get_child(rng.randi_range(0, columns*rows-1)) if temp_slot.empty(): slot = temp_slot break return slot func add_item(): #Слот добавления случайного предмета, который подключен к кнопке var slot = get_empty_slot() if slot: var data = {"type":"", "count": 0} data.type = "item_type_" + str(rng.randi_range(1, 8)) data.count = rng.randi_range(1, 999) slot.update_data(data)
И не забудь подключить сигнал кнопки в методу “add_item”, и всё заработает.
*Видео*
Следующим шагом реализация D&D(Drag&Drop).
Для начала, нужно создать отдельную сцену итема, т.к. нам нужен в двух местах.
Выглядит дерево примерно так:
Сразу создадим внутренний скрипт для итема, он простой, чисто устанавливает значение.
extends PanelContainer onready var icon = $Icon onready var count = $Count const path_to_items_icons = "res://graphics/%s.png" func set_data(item_data): icon.texture = load(path_to_items_icons % item_data.type) #Динамическая загрузка иконки count.text = str(item_data.count)
Далее приступаем к слоту:
Сюда мы закинули нашу сцену с итемом, плюс добавился лейбл “Num”, в нём лежит номер слота, я его использовал для отладки, вы можете просто скрыть его или удалить из сцены и из скрипта главной сцены. Кстати о главной сцене, в ней тоже произошли изменения:
Добавился как раз наш итем, координатно ни к чему не привязанный (без контейнеров), а зачем читайте дальше)
Теперь самое сложное, это скрипт главной сцены, там произошло куча изменений, в общем смотрите:
extends Control export (int, 1, 20) var columns = 8 #кол-во столбцов инвентаря export (int, 1, 20) var rows = 4 #кол-во строчек инвентаря const slot_scene = preload("res://Slot.tscn") #Подгружаем при компиляции сцену слота onready var inv = $InvContainer/InvContent #Хранилище слотов onready var titem = $TempItem #Это как раз наш временный итем, он нужен для отображения перетаскивания onready var rng = RandomNumberGenerator.new() #Инициализация объекта класса рандомайзера onready var item_dragging = null #Здесь хранится итем при перетаскивании onready var prev_slot = null #Слот из которого мы перетаскиваем итем func _ready(): titem.visible = false #скрываем временный итем rng.randomize() #запускаем рандомайзер $InvContainer/HBoxContainer/Clear.connect("pressed", self, "clear_inventory") $InvContainer/HBoxContainer/Add.connect("pressed", self, "add_item") inv.columns = columns #ограничиваем кол-во слолбцов отображения for i in range(columns*rows): #Цикл создания слотов var slot = slot_scene.instance() #Создаём объект слота slot.name = "Slot_%d" % i #Задаём ему имя, в целом не обязательное действия, но для отладки удобно slot.get_node("Num").text = str(i) #Как раз тот самый номер слота, если удаляете из сцены слота # текстовое поле, то эту строчку тоже нужно удалить inv.add_child(slot) #Добавление слота в хранилище func clear_inventory(): #Функция очистки хранилища for child in inv.get_children(): #Пробегаем по чилдам инвентаря child.update_data() #делаем апдейт без параметров func has_empty_slot(): #Метод проверки наличия хотя бы одной пустой ячеки for child in inv.get_children(): #Пробегаем по чилдам инвентаря if child.empty(): return true return false func get_empty_slot(): #Метод получения случайной пустой ячеки var slot = null if has_empty_slot(): #Обязательно нужно проверить, что у нас есть пустые ячейки #Иначе при полном инвентаре будет бесконечный цикл при полном инвентаре и игра зависнет while slot == null: #Ищем случайную пустую ячейку, пока не найдём var temp_slot = inv.get_child(rng.randi_range(0, columns*rows-1)) if temp_slot.empty(): slot = temp_slot break return slot func add_item(): #Слот добавления случайного предмета, который подключен к кнопке var slot = get_empty_slot() if slot: var data = {"type":"", "count": 0} data.type = "item_type_" + str(rng.randi_range(1, 8)) data.count = rng.randi_range(1, 999) slot.update_data(data) func find_slot(pos:Vector2, need_data = false): #Метод поиска слота по координатам #второй параметр - необязательный, он говорит функции искать в позиции слот с итемом или нет for c in inv.get_children(): #Пробегаем по чилдам инвентаря if (need_data and not c.empty()) or (not need_data): if Rect2(c.rect_position, c.rect_size).has_point(pos): #Создаём прямоугольник из координат слота и его размеров, чтобы #легко одним методом проверить находится ли точка в этом прямоугольнике return c return null func _process(delta): var mouse_pos = get_viewport().get_mouse_position() #Получаем позицию мышки if Input.get_mouse_button_mask() == BUTTON_LEFT: #Проверяем нажата ли левая кнопка мыши if not item_dragging: #если мы уже не тащим элемент var slot = find_slot(mouse_pos, true)#ищем под курсором слот с итемом if slot: #если слот найден item_dragging = slot.item_data #сохраняем в хранилище данные итема titem.set_data(item_dragging) #во временнный итем пихаем данные titem.visible = true #показываем временный итем titem.rect_position = slot.rect_position #перемещаем временный итем в координаты слота prev_slot = slot #сохраняем слот из которого будем тащить итем slot.update_data() #очищаем слот из которого тащим else: #если мы уже тащим итем, то перемещаем временный итем под курсор, со смещением от половины размера итема(чтобы центр итема был под курсором) titem.rect_position = lerp(titem.rect_position, mouse_pos - titem.rect_size/2, 0.3) else: #если кнопка отпущена if item_dragging: #если у нас в хранилище есть итем var slot = find_slot(mouse_pos, false) #Ищет слот под курсором if slot: #если он есть, то пытаемся закинуть в слот данные if not slot.update_data(item_dragging): #если не получилось, то возвращаем итем обратно prev_slot.update_data(item_dragging) prev_slot = null #очищаем ссылку на старый слот item_dragging = null #сбрасываем хранилище итема titem.visible = false #скрываем временный итем
Я постарался и прокомментировал практически каждую строчку, и получаем такой результат:
Чтобы нам ещё хотелось ? Я бы сделал обмен между слотами, мусорку и в конце будет ещё бонус)
Для начала дополним и чуть изменим скрипт слота
func check_data(data): return "all" in available_types or data.type in available_types func update_data(data = null): item.visible = data != null item_data = data if item_data: if check_data(data): item.set_data(item_data) return true return false return true
Теперь главный скрипт, в нём нужно поменять лишь функцию _process:
func _process(delta): var mouse_pos = get_viewport().get_mouse_position() #Получаем позицию мышки if Input.get_mouse_button_mask() == BUTTON_LEFT: #Проверяем нажата ли левая кнопка мыши if not item_dragging: #если мы уже не тащим элемент var slot = find_slot(mouse_pos, true)#ищем под курсором слот с итемом if slot: #если слот найден item_dragging = slot.item_data #сохраняем в хранилище данные итема titem.set_data(item_dragging) #во временнный итем пихаем данные titem.visible = true #показываем временный итем titem.rect_position = slot.rect_position #перемещаем временный итем в координаты слота prev_slot = slot #сохраняем слот из которого будем тащить итем slot.update_data() #очищаем слот из которого тащим else: #если мы уже тащим итем, то перемещаем временный итем под курсор, со смещением от половины размера итема(чтобы центр итема был под курсором) titem.rect_position = lerp(titem.rect_position, mouse_pos - titem.rect_size/2, 0.3) else: #если кнопка отпущена if item_dragging: #если у нас в хранилище есть итем var slot = find_slot(mouse_pos) #Ищет слот под курсором # Вариант №1 # if slot: #если он есть, то пытаемся закинуть в слот данные # if slot.empty(): #если в слот пустой # if slot.check_data(item_dragging): #подходит ли данные к слоту, то обновляем данные # slot.update_data(item_dragging) # else: #если нет, то возвращаем итем обратно # prev_slot.update_data(item_dragging) # else: #если слот не пустой, то проверяем подходят ли данные для обмена, если подходят меняем местами # if slot.check_data(item_dragging) and prev_slot.check_data(slot.item_data): # prev_slot.update_data(slot.item_data) # slot.update_data(item_dragging) # else: #если нет, то возвращаем обратно # prev_slot.update_data(item_dragging) # Вариант №2 if slot: #если слот найден if slot.check_data(item_dragging): #сразу проверям подходит ли к новому слоту данные, тобишь имеет ли смысл делать проверки дальше if slot.empty(): #если в слот пустой slot.update_data(item_dragging) else: #если слот не пустой, то проверяем подходят ли данные найденного слота для предыдущего if prev_slot.check_data(slot.item_data): #если подходит, то обновляем prev_slot.update_data(slot.item_data) slot.update_data(item_dragging) else: prev_slot.update_data(item_dragging) prev_slot = null #очищаем ссылку на старый слот item_dragging = null #сбрасываем хранилище итема titem.visible = false #скрываем временный итем
Думаю дополнительное объяснение излишне, единственное хотел бы пояснить зачем два варианта блока условий, оба выполняют одну и ту же задачу, работают одинаково верно, но оцените читаемость первого и второго, сначала мой на скорую руку был набросан первый вариант, задачу выполнял, но читаемость были никакая, написал я его вчера, а сегодня, когда дописывал статью не смог сразу понять чё там происходит, так же и в реальном продакшен коде, зачастую попадаются именно такие куски кода, где без 100 грамм не разберёшься, поэтому бесплатный совет, пишите так, чтобы ваш код понял даже медведь, не говоря уже о возможном психопате после вас, который знает ваш адрес)
Это был обмен, теперь мусорка, я решил сделать у слота специальный мета-тип, который будет определять алгоритм работы слота, если бы в годо было адекватное объектно- ориентированное программирование, тогда бы можно было просто наследоваться от класса слота и переопределить методы принятия данных и проверки данных, но нам придётся лепить условия.
Подправляем скрипт слота:
extends PanelContainer signal dropped(data) export (Array) var available_types = ["all"] #массив для ограничения доступности типов предметов для этой ячейки enum Actions {NONE, TRASH} #Перечисление с допустимиы действиями слота var cur_act = Actions.NONE #установка переменной действия слота в стандартное положение onready var item = $Item var item_data = null #Здесь будет словарь с данными предмета func _ready(): update_data() func set_action(new_value): cur_act = new_value $Item.visible = false $Trash.visible = false match cur_act: Actions.NONE: $Item.visible = true Actions.TRASH: $Trash.visible = true func empty(): return item_data == null func check_data(data): if cur_act: return true return "all" in available_types or data.type in available_types func update_data(data = null): if data and cur_act: emit_signal("dropped", data) return true item.visible = data != null item_data = data if item_data: if check_data(data): item.set_data(item_data) return true return false return true
И подправляем главный скрипт:
func _ready(): titem.visible = false #скрываем временный итем rng.randomize() #запускаем рандомайзер $InvContainer/HBoxContainer/Clear.connect("pressed", self, "clear_inventory") $InvContainer/HBoxContainer/Add.connect("pressed", self, "add_item") inv.columns = columns #ограничиваем кол-во слолбцов отображения for i in range(columns*rows): #Цикл создания слотов var slot = slot_scene.instance() #Создаём объект слота slot.name = "Slot_%d" % i #Задаём ему имя, в целом не обязательное действия, но для отладки удобно slot.get_node("Num").text = str(i) #Как раз тот самый номер слота, если удаляете из сцены слота # текстовое поле, то эту строчку тоже нужно удалить slot.set_action(slot.Actions.NONE) if i == columns*rows-1: slot.set_action(slot.Actions.TRASH) slot.connect("dropped", self, "trash_dropped") inv.add_child(slot) #Добавление слота в хранилище func trash_dropped(data): print("dropped ", data)
Мы изменили цикл создания слотов в _ready, плюс добавили новую функцию дропа итема, на случай если вы захотите сделать в игре выброс предмета в мир.
Ну а теперь бонус, сделаем полноценный инвентарь игрока.
Добавляем доп панель для инвентаря и накидываем ещё слотов:
Helmet и другие это тоже слоты, как и те, которые мы генерируем.
В скрипте слота нужно чутка дополнить:
extends PanelContainer signal dropped(path, data) #Сигнал помещения итема в корзину signal accepted(path, data) #Сигнал помещения итема в слот export (Array) var available_types = ["all"] #массив для ограничения доступности типов предметов для этой ячейки enum Actions {NONE, TRASH} #Перечисление с допустимиы действиями слота var cur_act = Actions.NONE #установка переменной действия слота в стандартное положение onready var item = $Item var item_data = null #Здесь будет словарь с данными предмета func _ready(): set_action() update_data() func set_action(new_value = Actions.NONE): cur_act = new_value $Item.visible = false $Trash.visible = false $Num.visible = false match cur_act: Actions.NONE: $Item.visible = true # $Num.visible = true Actions.TRASH: $Trash.visible = true func empty(): return item_data == null func check_data(data): if cur_act: return true return "all" in available_types or data.type in available_types func update_data(data = null): if data and cur_act: emit_signal("dropped", get_path(), data) return true item.visible = data != null item_data = data if item_data: if check_data(data): item.set_data(item_data) emit_signal("accepted", get_path(), data) return true return false return true
Ну и теперь самое главное, скрипт главной цены:
extends Control export (int, 1, 20) var columns = 8 #кол-во столбцов инвентаря export (int, 1, 20) var rows = 4 #кол-во строчек инвентаря export (Array, NodePath) var slots_containers # Экспортная переменная с массивом хранилищ слотов onready var slots = [] #Массив слотов const slot_scene = preload("res://scenes/Slot.tscn") #Подгружаем при компиляции сцену слота onready var inv = $PlayerInv/Inv/InvContent #Хранилище слотов onready var titem = $TempItem #Это как раз наш временный итем, он нужен для отображения перетаскивания onready var clearButton = $PlayerInv/Inv/Button/Clear onready var addButton = $PlayerInv/Inv/Button/Add onready var rng = RandomNumberGenerator.new() #Инициализация объекта класса рандомайзера onready var item_dragging = null #Здесь хранится итем при перетаскивании onready var prev_slot = null #Слот из которого мы перетаскиваем итем func _ready(): titem.visible = false #скрываем временный итем rng.randomize() #запускаем рандомайзер clearButton.connect("pressed", self, "clear_inventory") addButton.connect("pressed", self, "add_item") inv.columns = columns #ограничиваем кол-во слолбцов отображения for i in range(columns*rows): #Цикл создания слотов var slot = slot_scene.instance() #Создаём объект слота slot.name = "Slot_%d" % i #Задаём ему имя, в целом не обязательное действия, но для отладки удобно slot.get_node("Num").text = str(i) #Как раз тот самый номер слота, если удаляете из сцены слота # текстовое поле, то эту строчку тоже нужно удалить inv.add_child(slot) #Добавление слота в хранилище if i == columns*rows-1: slot.set_action(slot.Actions.TRASH) slots.push_back(slot) for slots_node in slots_containers: #Массив для перебора всех хранилищ слотов и помещении их в массив для удобства дальнейшего взаимодействия for slot in get_node(slots_node).get_children(): slots.push_back(slot) for slot in slots: slot.connect("accepted", self, "slot_accepted") slot.connect("dropped", self, "trash_dropped") func slot_accepted(path, data): print("accepted ", path, " ", data) func trash_dropped(path, data): print("dropped ", path, " ", data) func clear_inventory(): #Функция очистки хранилища for child in slots: #Пробегаем по всем слотам доступным child.update_data() #делаем апдейт без параметров func has_empty_slot(): #Метод проверки наличия хотя бы одной пустой ячеки for child in slots: #Пробегаем по всем слотам доступным if child.empty() and child.cur_act != child.Actions.TRASH: return true return false func get_empty_slot(): #Метод получения случайной пустой ячеки var rand_slot = null if has_empty_slot(): var empty_slots = [] #Массив пустых слотов for slot in slots: #Перебираем все слоты и ищем пустые и слоты с недопустимыми экшенами if slot.empty() and slot.cur_act != slot.Actions.TRASH: empty_slots.push_back(slot) rand_slot = empty_slots[(rng.randi_range(0, empty_slots.size()-1))] #выбираем случайный слот из пустых return rand_slot func add_item(): #Слот добавления случайного предмета, который подключен к кнопке var slot = get_empty_slot() if slot: var data = {"type":"", "count": 0} data.type = "item_type_" + str(rng.randi_range(1, 8)) data.count = rng.randi_range(1, 999) slot.update_data(data) func find_slot(pos:Vector2, need_data = false): #Метод поиска слота по координатам #второй параметр - необязательный, он говорит функции искать в позиции слот с итемом или нет for c in slots: #Пробегаем по чилдам инвентаря if (need_data and not c.empty()) or (not need_data): if c.get_global_rect().has_point(pos): #Создаём прямоугольник из координат слота и его размеров, чтобы #легко одним методом проверить находится ли точка в этом прямоугольнике return c return null func _process(delta): var mouse_pos = get_viewport().get_mouse_position() #Получаем позицию мышки if Input.get_mouse_button_mask() == BUTTON_LEFT: #Проверяем нажата ли левая кнопка мыши if not item_dragging: #если мы уже не тащим элемент var slot = find_slot(mouse_pos, true)#ищем под курсором слот с итемом if slot: #если слот найден item_dragging = slot.item_data #сохраняем в хранилище данные итема titem.set_data(item_dragging) #во временнный итем пихаем данные titem.visible = true #показываем временный итем titem.rect_position = slot.get_global_rect().position #перемещаем временный итем в координаты слота prev_slot = slot #сохраняем слот из которого будем тащить итем slot.update_data() #очищаем слот из которого тащим else: #если мы уже тащим итем, то перемещаем временный итем под курсор, со смещением от половины размера итема(чтобы центр итема был под курсором) titem.rect_position = lerp(titem.rect_position, mouse_pos - titem.rect_size/2, 0.3) else: #если кнопка отпущена if item_dragging: #если у нас в хранилище есть итем var slot = find_slot(mouse_pos) #Ищет слот под курсором if slot: #если слот найден if slot.check_data(item_dragging): #сразу проверям подходит ли к новому слоту данные, тобишь имеет ли смысл делать проверки дальше if slot.empty(): #если в слот пустой slot.update_data(item_dragging) else: #если слот не пустой, то проверяем подходят ли данные найденного слота для предыдущего if prev_slot.check_data(slot.item_data): #если подходит, то обновляем prev_slot.update_data(slot.item_data) slot.update_data(item_dragging) else: prev_slot.update_data(item_dragging) prev_slot = null #очищаем ссылку на старый слот item_dragging = null #сбрасываем хранилище итема
На самом деле здесь есть ещё что дорабатывать, можно было бы отказаться от массива слотов и сделать всё через встроенное в Годо средство, но об этом в одной из следующих статей.
Полный листинг в моём гитхаб репозитории: https://github.com/holyslav/InventoryGodot
UPD: Подправил функцию get_empty_slot
в последнем листинге, чтобы убрать возможность попадания в бесконечный цикл. в гите так же обновлено.