GameMaker

GameMaker

Not enough ratings
Система сетчатого инвентаря
By морт черепной
В этом руководстве я объясню, как создать гибкую систему инвентаря-сетки для вашей игры, которая будет похожа на те, что есть в Deus Ex, S.T.A.L.K.E.R. и Pathologic 2. Вы сможете с нуля написать эту систему пошагово, либо, если у вас уже есть игра, внедрить ее в свой код. Я постарался написать это руководство максимально подробно, так что в нем будут затронуты некоторые принципы работы самого движка, но даже если вы используете не GameMaker, а другой движок, статья все равно может быть вам полезна.
   
Award
Favorite
Favorited
Unfavorite
Итоговый результат
Перед началом
Для старта понадобится объект игрока и какой-нибудь другой объект, пусть это будет сундук (oChest). У обоих объектов будет инвентарь.

Также подготовим несколько спрайтов на тест для визуализации предметов инвентаря. В этом руководстве размер одной ячейки в сетке равен 32 на 32 пикселя, поэтому и спрайты предметов размером 1 на 1 клетку будут иметь размер 32x32, предметов 1x2 - 32x64 и т.д.. spItemError нужен на случай ошибки загрузки предмета.



spApple - 32x32

spItemError - 32x32

spMysteriousPackage - 64x64

spWaterBottle - 32x64

Еще нужна комната, где мы будем тестировать систему инвентаря, я назову ее rTest. В ней нужно расставить экземпляры объектов игрока и сундука.
Инициализация глобальных переменных для работы с инвентарем
Нам понадобятся некоторые глобальные переменные для работы системы инвентаря. Безопасной практикой объявления глобальных переменных считается их объявление сразу после запуска игры. Есть несколько хороших способов объявить глобальные переменные:

  • в отдельной “стартовой” комнате, в коде создания комнаты (Room Creation Code)
  • в отдельном объекте-менеджере, размещенном в самой первой комнате и самым первым в списке очереди создания экземпляров объектов
  • в отдельном скрипте, который в свою очередь может быть вызван как в Room Creation Code, так и в событии Create объекта-менеджера

Я рекомендую объединить первые два пункта: создать отдельную комнату (rInit), которая будет самой первой при запуске, и разместить туда объект-менеджер (oGameManager), но только без использования Room Creation Code. oGameManager должен быть Persistent-объектом, то есть он не должен уничтожаться при смене комнат, а должен существовать с момента создания и до закрытия игры.


порядок смены комнат


свойства oGameManager

О том, почему я советую именно такой способ, я напишу отдельную статью. Когда она будет опубликована, здесь появится ссылка на нее.

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



В событии Create напишем следующее:

global.ItemDB = ds_map_create(); room_goto(rTest);

  • global.ItemDB - это список всех предметов, существующих в игре, представленный в виде структуры данных ds_map, которая хранит пары ключ-значение. В нашем случае ключ - это идентификатор предмета (id), а значение - структура, описывающая его (struct). При работе с инвентарем мы будем обращаться к предметам, хранящимся в global.ItemDB, через их идентификатор (ключ), и получать структуру, описывающую эти предметы (значение)
  • room_goto(index) перебрасывает нас в нужную комнату

Факт смены комнаты не помешает созданию следующих в очереди экземпляров (тех, что вы, возможно, позже разместите ниже oGameManager в меню, которое выделено на скриншоте), потому что функция room_goto(index) не сразу меняет комнату, а только по завершению всех событий текущего кадра игры. Например, сразу после oGameManager у меня стоит Persistent-объект oInputHandler, который управляет обработкой нажатий клавиш, и он успешно успевает создаться (событие Create) перед сменой комнаты.
ini-файл
Создадим ini-файл, в котором будут описаны предметы, которые мы будем загружать в память при запуске игры. Структура ini-файла такая:

[раздел]
ключ1=значение
ключ2=значение
ключ3=значение
...

В нашем случае каждый раздел - идентификатор отдельного предмета, ключи - характеристики предмета (имя, описание, тип и т.д.), а значения - это, неожиданно, значения характеристик. Для теста я использую такой ini-файл[drive.google.com], в котором описаны 3 разных предмета.

Каждый предмет здесь представлен в виде структуры из 7 полей:

Name - имя предмета (не то же самое, что и id)
Type - тип предмета (снаряжение, еда, оружие и т.д.)
Width, Height - размеры предмета в ячейкахMaxStack - максимальное количество предметов в одном стаке
Sprite - название спрайта (который вы создали в самом GameMaker - spApple, spWaterBottle и т.д.) предмета
Description - описание предмета

Разумеется, вы можете убрать часть полей или добавить новые.

Этот ini-файл нужно добавить по следующему пути: название_проекта\datafiles. После этого он появится в разделе Included Files.

Скрипт scInventoryGlobalDatabase
Создаем скрипт scInventoryGlobalDatabase, он будет предназначен для работы с самой базой данных предметов. В нем объявим функцию loadItemDefinitions, которая будет инициализировать global.ItemDB, считывая данные о предметах из ini-файла. Она будет выглядеть так:

function loadItemDefinitions() { ini_open("items.ini"); var i = 0; while (ini_section_exists(i)) { global.ItemDB[? i] = { Name: ini_read_string(i, "Name", "nameerror"), Type: ini_read_string(i, "Type", "typeerror"), Width: ini_read_real(i, "Width", 1), Height: ini_read_real(i, "Height", 1), MaxStack: ini_read_real(i, "MaxStack", 1), Sprite: asset_get_index(ini_read_string(i, "Sprite", "spItemError")), Description: ini_read_string(i, "Description", "descrerror") }; ++i; } ini_close(); }

Функция ini_open(fname) принимает в качестве аргумента строку, хранящую имя ini-файла с его расширением, она открывает файл для прочтения и редактирования.

Далее мы запускаем цикл, который заканчивается тогда, когда все предметы прочитаны. Чтобы цикл успешно прошелся по каждому предмету, они должны иметь идентификаторы со значением от i до N, где N - количество ваших предметов. Главное, чтобы на этом промежутке не было пропусков.

Выражение global.ItemDB[? i] означает, что мы обращаемся к элементу этого списка с ключом i (предмету с идентификатором i). Выражение global.ItemDB[i] в данном случае привело бы к ошибке при компиляции, потому что компилятор думал бы, что вы пытаетесь обратиться не к структуре данных map, а к обычному массиву.

Иными словами, конструкция [? i] - это акцессор (accessor), который нужен для быстрого доступа к определенному элементу, но для разных структур данных есть свои акцессоры. Например, для ds_grid, которую мы чуть позже будем использовать, акцессор выглядит так: [# i, j].

Создаем структуру для ключей каждого элемента global.ItemDB, состоящую из 7 полей, которые описывают предмет. В соответствии с типом считываемых данных используем либо ini_read_string (для строк), либо ini_read_real (для чисел). Обе функции принимают 3 аргумента. Первый - раздел в ini-файле (тот, что обернут в квадратные скобки), второй - ключ, значение которого нам нужно прочитать, третий - значение, которое будет возвращено функцией в случае неудачного прочтения, например, если указанного раздела или ключа в файле не существует.

В конце мы закрываем файл функцией ini_close().

Добавим в этот же скрипт следующую функцию:

function getItemFromGlobalDatabase(_itemID) { if (ds_map_exists(global.ItemDB, _itemID)) return global.ItemDB[? _itemID]; return undefined; }

Эта функция будет принимать идентификатор предмета и возвращать его структуру. Если мы передаем несуществующий идентификатор, функция вернет undefined.

В событии Create объекта oGameManager перед строкой room_goto(rTest); добавим следующую строчку:

loadItemDefinitions();

Она запустит функцию, которая проинициализирует global.ItemDB.

На этом работа с парсингом ini-файла и инициализации global.ItemDB закончена. Следующим этапом будет создание самого инвентаря.
Общие принципы работы инвентаря
  • Инвентарями мы сможем наделять как объект игрока, так и другие объекты (сундук, мусорное ведро на улице, другой NPC и т.д.)
  • Так как инвентарь, который мы создаем, будет сетчатым, мы воспользуемся встроенной в GameMaker структурой данных ds_grid, которая, по сути, является двумерным массивом с некоторыми улучшениями для упрощения работы. Наш инвентарь и будет являться контейнером ds_grid, просто мы напишем дополнительные функции для работы с ним
  • Предметы, размещаемые в сетке, могут иметь разные размеры (1x1, 1x2, 2x2 и т.д.), нам нужно это учитывать при добавлении, перемещении и удалении этих предметов. В структуре предмета есть поля, отвечающие за его размер (Width и Height)
  • В одной и той же ячейке может находиться несколько предметов одного типа, но не более, чем значение поля MaxStack структуры предмета

Учитывая все перечисленное, составим примерное описание того, как будет выглядеть хранение предметов в инвентаре:

  • Левая верхняя (основная) ячейка предмета в инвентаре - структура, содержащая поля itemID и quantity. Обращаясь к какому-либо предмету в инвентаре, мы будем обращаться именно к основной ячейке этого предмета, тем самым получать его идентификатор и количество таких предметов в этой ячейке, а так как мы знаем идентификатор, то можем и узнать всю информацию об этом предмете через функцию getItemFromGlobalDatabase(_itemID)
  • Все остальные ячейки предмета инвентаря, кроме основной - структуры со значениями refX и refY, которые являются координатами основной ячейки. Через побочные ячейки мы сможем находить основную
  • Если ячейка в сетке инвентаря ничем не занята, она будет принимать значение noone

Скрипт scInventoryGrid (часть 1)
В этом скрипте будут все функции для работы с сеткой инвентаря. На каждую функцию я оставил подробные комментарии.

Создаем сетку инвентаря

//создание инвентаря размерами _width на _height //возвращает созданную сетку инвентаря function inventoryCreate(_width, _height) { var grid = ds_grid_create(_width, _height); ds_grid_clear(grid, noone); //инициализируем все ячейки значением 'noone' (пусто) return grid; }

Уничтожаем сетку инвентаря

//уничтожение инвентаря //возвращает true в случае успешного удаления и false, если указанный инвентарь не существует function inventoryDestroy(_inventoryGrid) { //структуры, хранившиеся в сетке, будут удалены автоматически if (ds_exists(_inventoryGrid, ds_type_grid)) { ds_grid_destroy(_inventoryGrid); //просто уничтожаем саму сетку return true; } else { return false; } }

Получаем размеры предмета в клетках (ячейках). Здесь мы используем функцию getItemFromGlobalDatabase(_itemID), чтобы найти предмет по его id

//получаем размеры предмета в клетках (ячейках) //возвращает структуру, описывающую размеры предмета или undefined, если указанного предмета не существует function inventoryGetItemDimensions(_itemID) { var itemData = getItemFromGlobalDatabase(_itemID); //ищем предмет в ItemDB if (itemData != undefined) //нашли предмет в ItemDB { return { w : itemData.Width, h : itemData.Height }; } else //не нашли (предмета нет) { return undefined; } }

Функция, проверяющая возможность размещения предмета по указанным координатам. Аргументы _cellX и _cellY - координаты основной ячейки. Мы проверяем все ячейки, которые будет занимать предмет, отталкиваясь от координат основной. Таким образом, если мы, например, размещаем предмет размером 2x2 в ячейке (3;4), то функция вернет true только в том случае, если ячейки (3;4), (4;4), (3;5) и (4;5) будут свободны

//можем ли разместить предмет _itemID в инвентаре _inventoryGrid в ячейке с координатами (_cellX;_cellY) //возвращает true, если можем разместить предмет в указанном месте и false, если нет function inventoryCanPlace(_inventoryGrid, _itemID, _quantity, _cellX, _cellY) { //находим размеры предмета в ячейках var dims = inventoryGetItemDimensions(_itemID); if (dims == undefined) return false; var itemW = dims.w; var itemH = dims.h; //размеры сетки в клетках var gridW = ds_grid_width(_inventoryGrid); var gridH = ds_grid_height(_inventoryGrid); //проверка выхода за границы сетки if (_cellX < 0 || _cellY < 0 || _cellX + itemW > gridW || _cellY + itemH > gridH) return false; //проверка, не заняты ли ячейки for (var x_ = _cellX; x_ < _cellX + itemW; ++x_) for (var y_ = _cellY; y_ < _cellY + itemH; ++y_) if (_inventoryGrid[# x_, y_] != noone) //если ячейка занята return false; //место занято return true; //место свободно }

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

//добавляем в инвентарь _inventoryGrid предмет _itemID в количестве _quantity единиц в ячейку (_cellX;_cellY) //возвращает true при успешном добавлении и false, если предмет нельзя разместить function inventoryAddItemTo(_inventoryGrid, _itemID, _quantity, _cellX, _cellY) { //если не можем разместить предмет в указанной позиции if (!inventoryCanPlace(_inventoryGrid, _itemID, _quantity, _cellX, _cellY)) return false; //не удалось разместить предмет (размеры или коллизия с другими предметами) //получаем размеры предмета //не проверяем случай dims == undefined, потому что в inventoryCanPlace уже была проверка var dims = inventoryGetItemDimensions(_itemID); //создаем структуру для хранения данных предмета var itemToBePlaced = { itemID : _itemID, quantity : _quantity }; //помещаем предмет в основную ячейку _inventoryGrid[# _cellX, _cellY] = itemToBePlaced; //помечаем остальные ячейки так, чтобы они ссылались на основную ячейку for (var x_ = _cellX; x_ < _cellX + dims.w; ++x_) { for (var y_ = _cellY; y_ < _cellY + dims.h; ++y_) { if (x_ == _cellX && y_ == _cellY) continue; //пропускаем левую верхнюю (основную) ячейку _inventoryGrid[# x_, y_] = { refX: _cellX, refY: _cellY }; } } return true; //предмет успешно добавлен }
Скрипт scInventoryGrid (часть 2)
Находим координаты основной ячейки через побочную. Если передаем в качестве аргументов координаты основной ячейки, возвращаем ее же

//находим основную ячейку предмета по указанным координатам //возвращает: //undefined, если вышли за пределы сетки; //noone, если ячейка пустая; //структуру, содержащую координаты основной ячейки, если по указанным координатам есть предмет function inventoryGetMainItemCell(_inventoryGrid, _cellX, _cellY) { //находим размеры сетки в ячейках var gridW = ds_grid_width(_inventoryGrid); var gridH = ds_grid_height(_inventoryGrid); //проверяем, не вышли ли мы за пределы сетки if (_cellX < 0 || _cellY < 0 || _cellX >= gridW || _cellY >= gridH) return undefined; //смотрим содержимое ячейки var cell = _inventoryGrid[# _cellX, _cellY]; //проверяем, есть ли что-то в ячейке if (cell == noone) return noone; //ячейка пуста var mainX_, mainY_; //проверяем, является ли содержимое ячейки ссылкой на основную ячейку (структурой с ключами "refX" и "refY") if (variable_struct_exists(cell, "refX") && variable_struct_exists(cell, "refY")) { //находим координаты основной ячейки через побочную ячейку mainX_ = cell.refX; mainY_ = cell.refY; } else { //содержимое - и есть основная ячейка mainX_ = _cellX; mainY_ = _cellY; } return { mainX: mainX_, mainY: mainY_ } }

Функция, возвращающая структуру предмета из global.ItemDB через основную ячейку предмета. Для нахождения основной ячейки используем предыдущую функцию

//получаем предмет из инвентаря _inventoryGrid в ячейке (_cellX;_cellY) //возвращает: //undefined, если вышли за пределы сетки; //noone, если ячейка пустая; //основную ячейку предмета (структуру, содержащую поля itemID и quantity), если по указанным координатам есть предмет function inventoryGetItemAtCell(_inventoryGrid, _cellX, _cellY) { //ищем основную ячейку этого предмета var mainItemCellCoords = inventoryGetMainItemCell(_inventoryGrid, _cellX, _cellY); //если нажали на пустую ячейку или вышли за пределы сетки if (mainItemCellCoords == noone || mainItemCellCoords == undefined) return mainItemCellCoords; //нашли основную ячейку var mainX = mainItemCellCoords.mainX; var mainY = mainItemCellCoords.mainY; var item = _inventoryGrid[# mainX, mainY]; //находим сам предмет return item; //возвращаем предмет }

Функция, принимающая координаты ячейки и удаляющая предмет в этой ячейке через основную. Для этого также используем функцию inventoryGetMainItemCell(_inventoryGrid, _cellX, _cellY). Здесь мы проходимся по всем ячейкам этого предмета и присваиваем им noone, что в дальнейшем будет сигнализировать о том, что ячейки пустые

//убираем из инвентаря _inventoryGrid предмет в ячейке (_cellX;_cellY) //возвращает: //false, если передали координаты пустой ячейки или вышли за пределы сетки; //основную ячейку удаленного предмета, если предмет успешно удален function inventoryRemoveItemAt(_inventoryGrid, _cellX, _cellY) { //ищем основную ячейку этого предмета var mainItemCellCoords = inventoryGetMainItemCell(_inventoryGrid, _cellX, _cellY); //если нажали на пустую ячейку или вышли за пределы сетки if (mainItemCellCoords == noone || mainItemCellCoords == undefined) return false; //нашли основную ячейку var mainX = mainItemCellCoords.mainX; var mainY = mainItemCellCoords.mainY; var item = _inventoryGrid[# mainX, mainY]; //находим сам предмет var dims = inventoryGetItemDimensions(item.itemID); //получаем размеры предмета для очистки всех ячеек //очищаем все ячейки, занятые этим предметом (ставим noone) for (var x_ = mainX; x_ < mainX + dims.w; ++x_) for (var y_ = mainY; y_ < mainY + dims.h; ++y_) _inventoryGrid[# x_, y_] = noone; return item; //возвращаем данные удаленного предмета }

Наш инвентарь будет отображаться как элемент интерфейса в виде сетки в левом верхнем (или любом другом) углу, поэтому нам понадобится функция, переводящая экранные координаты (в пикселях) в координаты сетки (в ячейках), чтобы мышью управлять инвентарем: при наведении мыши на инвентарь мы будем получить координаты той ячейки, над которой она “висит”. Самой обработки движений и нажатий мыши здесь еще нет, ее логика будет прописана в другом месте. Функция принимает в качестве аргументов сам инвентарь, координаты точки в пикселях, координаты начала инвентаря и размер ячейки. Так как функция учитывает расположение сетки инвентаря, саму сетку мы сможем размещать как угодно (об отрисовке сетки позже), и нам не нужно будет менять или дополнять код в этой функции

//переводим экранные координаты (в пикселях) в координаты сетки (в ячейках) //возвращает: //undefined, если рассматриваемые координаты за пределами области сетки инвентаря; //структуру, хранящую координаты ячейки, которым соответствуют экранные координаты, если координаты точки расположены внутри сетки function inventoryScreenToGridCoords(_inventoryGrid, _screenX, _screenY, _gridStartX, _gridStartY, _cellSize) { //находим размеры сетки инвентаря (в ячейках) var gridW = ds_grid_width(_inventoryGrid); var gridH = ds_grid_height(_inventoryGrid); //находим размеры сетки инвентаря (в пикселях) var totalW = gridW * _cellSize; var totalH = gridH * _cellSize; //если рассматриваемые координаты за пределами области сетки инвентаря if (_screenX < _gridStartX || _screenX >= _gridStartX + totalW || _screenY < _gridStartY || _screenY >= _gridStartY + totalH) return undefined; //вычисляем координаты в ячейках var cellX_ = floor((_screenX - _gridStartX) / _cellSize); var cellY_ = floor((_screenY - _gridStartY) / _cellSize); return { x_ : cellX_, y_ : cellY_ }; }
Создание инвентарей и добавление предметов в них
Теперь мы можем добавлять инвентари объектам. В событии Create любого объекта, который вы хотите наделить инвентарем, пишите следующую строчку:

inventory = inventoryCreate(_width, _height);

где _width и _height - ширина и высота сетки соответственно.

И обязательно в событии Clean Up необходимо добавить эту строку:

inventoryDestroy(inventory);

Без этой строки при уничтожении экземпляра объекта сетка продолжит существовать, а так как переменная, ссылающаяся на нее (inventory), будет удалена вместе с экземпляром объекта, получить доступ к этой сетке вы больше не сможете, и это приведет к утечке памяти.

Для добавления предметов в инвентарь, необходимо использовать функцию inventoryAddItemTo(_inventoryGrid, _itemID, _quantity, _cellX, _cellY).

Для инвентаря объекта игрока я добавлю в событии Create следующие строки:

inventory = inventoryCreate(7, 5); inventoryAddItemTo(inventory, 0, 3, 0, 0); inventoryAddItemTo(inventory, 1, 1, 3, 3); inventoryAddItemTo(inventory, 2, 1, 3, 0); inventoryAddItemTo(inventory, 1, 2, 4, 4); inventoryAddItemTo(inventory, 1, 3, 5, 4);


чуть позже, когда мы напишем код для отрисовки сетки, код выше приведет к такому результату

Также создадим инвентарь для сундука и добавим туда несколько предметов:

inventory = inventoryCreate(5, 5); inventoryAddItemTo(inventory, 0, 3, 1, 2); inventoryAddItemTo(inventory, 1, 2, 0, 0);


сундук
Менеджер инвентаря
Теперь нам необходим объект, который будет отвечать за отрисовку интерфейса и обработку пользовательских нажатий относительно инвентаря. Назовем этот объект oInventoryManager. Создание такого менеджера поможет отделить код для вышеперечисленного функционала от всей остальной игры, что позволит при необходимости с легкостью его отредактировать или деактивировать без путаницы и последствий. Этот менеджер будет обрабатывать как инвентарь игрока в одиночку, так и два инвентаря одновременно, например, для лутинга сундука (или торговли/бартера между игроком и NPC - при соответствующей доработке этой системы).

Создаем oInventoryManager. НЕ делаем его Persistent: он будет существовать только когда мы открываем инвентарь, а в момент закрытия он будет удаляться. В событии Create напишем такой код:

//ссылки на инвентари invPlayer = noone; invOther = noone; //инвентарь и ячейка, над которыми "висит" курсор hoverInv = noone; hoverCell = undefined; //размер ячейки cellSize = 32; //позиции для отрисовки инвентарей на экране (левые верхние точки инвентарей) playerInvX = 50; playerInvY = 50; otherInvX = 500; otherInvY = 50; //ДЛЯ ПЕРЕТАСКИВАНИЯ //перетаскиваемый предмет draggedItem = noone; isDragging = function() { if (draggedItem != noone) return true; return false; } //исходный инвентарь перетаскиваемого предмета draggedItemOriginalInv = noone; //исходная позиция перетаскиваемого предмета draggedItemOriginalX = 0; draggedItemOriginalY = 0;

Здесь уже есть комментарии, объясняющие, что эти переменные и функция делают.

Теперь в событии Destroy напишем следующее:

//если закрыли инвентарь в момент перетаскивания, перетаскиваемый предмет возвращаем в то же место, откуда взяли его if (isDragging()) { var inv = draggedItemOriginalInv; var itemID = draggedItem.itemID; var quantity = draggedItem.quantity; var targetX = draggedItemOriginalX; var targetY = draggedItemOriginalY; inventoryAddItemTo(inv, itemID, quantity, targetX, targetY); }

Без этих строк при закрытии инвентаря предмет будет просто исчезать.

Теперь нам понадобятся функции, которые будут отвечать за создание/уничтожение oInventoryManager. Эти функции мы не будем писать в самом oInventoryManager, потому что в таком случае мы не сможем вызвать функцию создания этого объекта (его же еще не существует), так что создадим скрипт scInventoryManager и напишем там следующее:

function inventoryManagerCreateSingle() { //если менеджер уже есть, закрываем его if (instance_exists(oInventoryManager)) return; //создаем менеджер instance_create_layer(0, 0, "UI", oInventoryManager); //слой UI //если менеджер создан, он обязательно должен обрабатывать как минимум инвентарь игрока oInventoryManager.invPlayer = oPlayer.inventory; } function inventoryManagerCreateDouble(_invOther = noone) { if (instance_exists(oInventoryManager)) return; var invOther = noone; //если инвентарь, с которым будем взаимодействовать, передан как аргумент, то менеджер обрабатывает его if (_invOther != noone) { invOther = _invOther; } //иначе ищем ближайший сундук. если можем дотянуться до него (InteractionDistance), то менеджер обрабатывает его else { var chest = instance_nearest(oPlayer.x, oPlayer.y, oChest); if (chest != noone && point_distance(oPlayer.x, oPlayer.y, chest.x, chest.y) < InteractionDistance) invOther = chest.inventory; //инвентарь сундука, с которым взаимодействуем } //если invOther так и остался noone, то и не создаем менеджер if (invOther == noone) return; instance_create_layer(0, 0, "UI", oInventoryManager); //слой UI oInventoryManager.invPlayer = oPlayer.inventory; oInventoryManager.invOther = invOther; } function inventoryManagerDestroy() { if (instance_exists(oInventoryManager)) instance_destroy(oInventoryManager); }

Здесь описаны три функции:

  • inventoryManagerCreateSingle() создает менеджер инвентаря, инициализируя переменную invPlayer инвентарем игрока и оставляя переменную invOther равной noone, что будет означать, что второго инвентаря, с которым мы бы взаимодействовали, нет - то есть игрок просто открывает свой инвентарь
  • Функция inventoryManagerCreateDouble(_invOther = noone) служит для создания менеджера инвентаря с присвоением invOther какого-нибудь другого инвентаря, с которым бы взаимодействовал игрок. Здесь, если мы передаем в качестве аргумента какой-то конкретный инвентарь, то менеджер будет создаваться с ним, а если нет, то игра проверит наличие сундука в комнате и найдет ближайший

Обратите внимание, что в этой функции фигурирует константа InteractionDistance - я ее объявил в событии Create объекта oGameManager следующим образом:

#macro InteractionDistance 100

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

  • Третья функция просто удаляет oInventoryManager при условии, что он существует

Условия, при которых будут вызываться эти функции, могут быть разными:

  • Вы добавили игроку в инвентарь квестовый предмет и сразу хотите это продемонстрировать, открыв его - вызываете inventoryManagerCreateSingle()
  • Игрок нажал на “Бартер” в диалоге с NPC - вызываете inventoryManagerCreateDouble(_invOther), где _invOther - его инвентарь
  • Вы, управляя игроком, подошли к сундуку и нажали на E - вызываете inventoryManagerCreateDouble() (без аргумента)
  • Вы, управляя игроком, нажали на I - вызываете inventoryManagerCreateSingle()

Давайте реализуем функционал последних двух способов.

Обработку нажатий клавиш я советую осуществлять в событии Step отдельного объекта - oInputHandler. Создайте его, разместите в rInit (инициализирующей комнате) и сделайте Persistent.

Добавьте в Create такие строки:

btnInventory = "I"; btnInteract = "E";

Это будут кнопки, отвечающие за открытие инвентаря. В событии Step напишите:

//ИНВЕНТАРЬ //только игрок if (keyboard_check_pressed(ord(btnInventory))) inventoryManagerCreateSingle(); //игрок + сундук if (keyboard_check_pressed(ord(btnInteract))) inventoryManagerCreateDouble(); //выход из инвентаря if (keyboard_check_pressed(vk_escape)) inventoryManagerDestroy();

Теперь при нажатии на I или E менеджер инвентаря будет создаваться, а при нажатии на Escape - уничтожаться. Чтобы открыть сундук, нужно подойти к нему на достаточное расстояние.

Подробнее об oInputHandler я напишу отдельную статью. Когда она будет опубликована, здесь появится ссылка на нее.
Визуальное отображение инвентаря
Начнем с кода для отрисовки сетки инвентаря, но сперва нужно создать какой-нибудь шрифт. Если у вас еще ни одного шрифта в проекте нет, можете создать обычный Arial.



В событии Draw GUI добавьте draw_set_font(font);, где font - название вашего шрифта. В данном случае это fArial.

Далее создадим функцию для отрисовки самой сетки инвентаря и предметов, находящихся в ней. Разместим объявление этой функции в событии Create, в самом конце:

//Ф-Я ДЛЯ ОТРИСОВКИ УКАЗАННОГО ИНВЕНТАРЯ drawInventoryGrid = function(_inventoryGrid, _startX, _startY) { var gridW = ds_grid_width(_inventoryGrid); var gridH = ds_grid_height(_inventoryGrid); //рисуем фон сетки for (var x_ = 0; x_ < gridW; ++x_) { for (var y_ = 0; y_ < gridH; ++y_) { var drawX = _startX + x_ * cellSize; var drawY = _startY + y_ * cellSize; draw_set_color(c_black); draw_rectangle(drawX, drawY, drawX + cellSize, drawY + cellSize, false); draw_set_color(c_dkgray); draw_rectangle(drawX, drawY, drawX + cellSize, drawY + cellSize, true); } } //отрисовка всех предметов в сетке for (var x_ = 0; x_ < gridW; ++x_) { for (var y_ = 0; y_ < gridH; ++y_) { var cellContent = _inventoryGrid[# x_, y_]; //рисуем, только если это структура с ключом "itemID" if (is_struct(cellContent) && variable_struct_exists(cellContent, "itemID")) { var item = cellContent; var itemID = item.itemID; var quantity = item.quantity; var itemData = getItemFromGlobalDatabase(itemID); var sprite = itemData.Sprite; var itemWidth = itemData.Width; var itemHeight = itemData.Height; var drawX = _startX + x_ * cellSize; var drawY = _startY + y_ * cellSize; var drawW = itemWidth * cellSize; var drawH = itemHeight * cellSize; //рисуем спрайт draw_set_alpha(1); if (sprite_exists(sprite)) draw_sprite(sprite, 0, drawX, drawY); else draw_sprite(spItemError, 0, drawX, drawY); //рисуем количество if (itemData.MaxStack > 1 && quantity > 1) { draw_set_color(c_white); draw_set_halign(fa_right); draw_set_valign(fa_bottom); draw_text_color(drawX + drawW + 1, drawY + drawH + 1, string(quantity), c_black, c_black, c_black, c_black, 1); //тень draw_text(drawX + drawW, drawY + drawH, string(quantity)); } } } } };

Функция в качестве аргументов принимает инвентарь, который нужно отрисовать, и левый верхний угол этого инвентаря. Эту функцию мы будем вызывать каждый кадр игры в Draw GUI для инвентаря игрока и при необходимости для инвентаря, с которым взаимодействует игрок. Давайте разберем этот код по частям:

Сначала мы рисуем саму сетку, по ячейкам. Функция draw_rectangle(drawX, drawY, drawX + cellSize, drawY + cellSize, false); рисует черную ячейку, а draw_rectangle(drawX, drawY, drawX + cellSize, drawY + cellSize, true); рисует обводку для этой ячейки.

Затем отрисовываем предметы. Для этого проходимся по всему ds_grid и ищем элементы, являющиеся структурами и содержащие поле itemID - это левые верхние углы предметов. Элементы, являющиеся ссылками на основную ячейку и пустыми ячейками, разумеется, пропускаем. Так как мы начинаем отрисовку с левого верхнего угла предмета, то и точка привязки спрайта должна находиться в левом верхнем углу. Убедитесь, что для каждого спрайта вы выбрали точку привязки Top Left (Origin = 0x0).



После отрисовки содержимого ячейки отрисовываем количество предметов в ней. Количество будет находиться в правом нижнем углу предмета.

Теперь применим эту функцию в Draw GUI, а также добавим надписи сверху, чтобы понимать, где инвентарь игрока, а где другой:

draw_set_font(fArial); //ОТРИСОВЫВАЕМ ИНВЕНТАРИ: ИГРОКА И ДРУГОЙ, ЕСЛИ ОН ЕСТЬ //отрисовка для инвентаря игрока if (invPlayer != noone) { drawInventoryGrid(invPlayer, playerInvX, playerInvY); //пишем название инвентаря сверху от сетки draw_set_color(c_white); draw_set_halign(fa_left); draw_set_valign(fa_bottom); draw_text(playerInvX, playerInvY - 5, "Player"); } //отрисовка для другого инвентаря (если он есть) if (invOther != noone) { drawInventoryGrid(invOther, otherInvX, otherInvY); //пишем название инвентаря сверху от сетки draw_set_color(c_white); draw_set_halign(fa_left); draw_set_valign(fa_bottom); draw_text(otherInvX, otherInvY - 5, "Other"); }

Теперь попробуем запустить игру. При открытии сундука видим такой результат:



Сейчас с этими инвентарями мы ничего не можем сделать, поэтому напишем код для перетаскивания предметов.
Обработка нажатий инвентаря
В событии Step все того же oInventoryManager напишем такие строки:

//если игрок подвинулся, то он выходит из инвентаря if (oPlayer.dx != 0) instance_destroy(self); //получаем координаты мыши в GUI var mouseX = device_mouse_x_to_gui(0); var mouseY = device_mouse_y_to_gui(0); //определяем, над какой сеткой и ячейкой висит курсор hoverInv = noone; hoverCell = undefined; //проверяем инвентарь игрока if (invPlayer != noone) { hoverCell = inventoryScreenToGridCoords(invPlayer, mouseX, mouseY, playerInvX, playerInvY, cellSize); if (hoverCell != undefined) hoverInv = invPlayer; } //если не над инвентарем игрока, то проверяем другой (если он есть) if (hoverInv == noone && invOther != noone) { hoverCell = inventoryScreenToGridCoords(invOther, mouseX, mouseY, otherInvX, otherInvY, cellSize); if (hoverCell != undefined) hoverInv = invOther; } //если курсор не висит над каким-либо инвентарем, останавливаем выполнение события if (hoverInv == noone) return;

В начале события стоит проверка, двигается ли объект игрока. Если да, то закрываем инвентарь. В моем случае движение игрока определяется переменной dx: если она не равна 0, то игрок находится в движении. У вас эта проверка может происходить по-другому или ее вообще может не быть.

Затем смотрим на расположение курсора и проверяем, заходит ли он на какой-нибудь из инвентарей. Если да, сразу ищем ячейку, над которой “висит” курсор, используя ранее написанную функцию для перевода экранных координат в координаты инвентаря.

Если курсор за пределами сеток инвентаря, дальнейшее выполнение события не имеет смысла, поэтому останавливаем его.

Теперь напишем саму логику перетаскивания. Оно будет осуществляться с помощью нажатий левой кнопки мыши:

//ЛОГИКА ПЕРЕТАСКИВАНИЯ //начало перетаскивания (нажатие левой кнопки мыши по предмету) if (mouse_check_button_pressed(mb_left) && !isDragging()) { //координаты ячейки, куда нажали var cellX = hoverCell.x_; var cellY = hoverCell.y_; var item = inventoryGetItemAtCell(hoverInv, cellX, cellY); if (item == noone || item == undefined) //останавливаем событие, если предмета в этой ячейке нет return; item = inventoryRemoveItemAt(hoverInv, cellX, cellY); //убираем предмет из сетки if (item == false) //останавливаем событие, если не удалось удалить предмет return; //запоминаем перетаскиваемый предмет, инвентарь, откуда он был взят и его исходную позицию draggedItem = item; draggedItemOriginalInv = hoverInv; draggedItemOriginalX = cellX; draggedItemOriginalY = cellY; } //конец перетаскивания (нажатие лкм при наличии перетаскиваемого предмета) else if (mouse_check_button_pressed(mb_left) && isDragging()) { //координаты ячейки, над которой курсор var cellX = hoverCell.x_; var cellY = hoverCell.y_; //смотрим содержимое этой ячейки var targetCell = inventoryGetItemAtCell(hoverInv, cellX, cellY); //если вышли за пределы сеток инвентарей, игнорируем нажатие if (targetCell == undefined) return; //ВАРИАНТ 1: в ячейке есть такой же предмет, стакуем их if (targetCell != noone) { var targetItem = targetCell; //если этот предмет тот же самый, что и тот, что в данный момент перетаскивается if (targetItem.itemID == draggedItem.itemID) { var itemData = getItemFromGlobalDatabase(draggedItem.itemID); var maxStack = itemData.MaxStack; //если есть место в стаке if (targetItem.quantity < maxStack) { var spaceAvailable = maxStack - targetItem.quantity; //вычисляем доступное место var amountToMove = min(draggedItem.quantity, spaceAvailable); //сколько положим в стопку //добавляем к стопке в инвентаре targetItem.quantity += amountToMove; //уменьшаем у перетаскиваемой стопки draggedItem.quantity -= amountToMove; //если мы перенесли все, завершаем перетаскивание (иначе перетаскивание продолжается с остатком) if (draggedItem.quantity == 0) draggedItem = noone; } } } //ВАРИАНТ 2: в ячейке нет предмета else { //пробуем разместить предмет var isPlaced = inventoryAddItemTo(hoverInv, draggedItem.itemID, draggedItem.quantity, hoverCell.x_, hoverCell.y_); //при успешном размещении обнуляем переменную if (isPlaced) draggedItem = noone; } }

Теперь добавим в Draw GUI код для отрисовки перетаскиваемого предмета, сразу после кода для отрисовки сеток:

//ОТРИСОВКА ПЕРЕТАСКИВАЕМОГО ПРЕДМЕТА //рисуем проекцию перетаскиваемого предмета //(белые квадраты будут сигнализировать о возможности расположить предмет) if (isDragging() && hoverInv != noone) { var itemID = draggedItem.itemID; var quantity = draggedItem.quantity; if (inventoryCanPlace(hoverInv, itemID, quantity, hoverCell.x_, hoverCell.y_)) { var itemData = getItemFromGlobalDatabase(itemID); var invX, invY; if (hoverInv == invPlayer) { invX = playerInvX; invY = playerInvY; } else { invX = otherInvX; invY = otherInvY; } for (var i = hoverCell.x_; i < hoverCell.x_ + itemData.Width; ++i) { for (var j = hoverCell.y_; j < hoverCell.y_ + itemData.Height; ++j) { var drawX = invX + i * cellSize; var drawY = invY + j * cellSize; draw_set_alpha(0.5); draw_set_color(c_white); draw_rectangle(drawX, drawY, drawX + cellSize - 1, drawY + cellSize - 1, false); } } } } //отрисовка перетаскиваемого предмета if (isDragging()) { var itemID = draggedItem.itemID; var quantity = draggedItem.quantity; var itemData = getItemFromGlobalDatabase(itemID); var drawX = device_mouse_x_to_gui(0); var drawY = device_mouse_y_to_gui(0); //рисуем спрайт draw_set_alpha(0.5); draw_set_halign(fa_left); draw_set_valign(fa_top); draw_sprite(itemData.Sprite, 0, drawX, drawY); //рисуем кол-во, если больше одного предмета if (itemData.MaxStack > 1 && quantity > 1) { var textX = drawX + itemData.Width*cellSize; var textY = drawY + itemData.Height*cellSize; draw_set_alpha(1); draw_set_halign(fa_right); draw_set_valign(fa_bottom); draw_set_color(c_white); draw_text_color(textX + 1, textY + 1, string(quantity), c_black, c_black, c_black, c_black, 1); //тень draw_text(textX, textY, string(quantity)); } } draw_set_halign(fa_left); draw_set_valign(fa_top); draw_set_color(c_white); draw_set_alpha(1);

Сначала рисуем проекцию перетаскиваемого предмета. Это белые полупрозрачные квадраты, которые рисуются в том месте, где расположится предмет при нажатии на ЛКМ.

Затем отрисовываем перетаскиваемый предмет. Он будет полупрозрачным и перемещаться вместе с курсором.
Итог
Что дальше?
Вот идеи для доработки этой системы:

  • Добавить возможность выбирать, сколько предметов перетаскивать (сейчас можно только перетаскивать весь стак)
  • Добавить функцию для добавления предмета в свободное пространство инвентаря без указания конкретных координат. Она может понадобиться, например, для получения квестовых предметов
  • Для идеи, описанной выше, добавить обработку ситуации, когда для предмета не хватает места, например, выбросить его на землю
  • Добавить функцию для удаления предмета из инвентаря не по координатам, а по его ID
  • Добавить контекстное меню для взаимодействия с предметами (яблоко - съесть, коробку - вскрыть, броню - экипировать и т.д.)

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

Скачать исходники проекта можно здесь[drive.google.com], он скомпилирован на версии v2024.13.1.193 (Steam).

Спасибо за внимание! Если есть вопросы - пишите в комментариях, на все отвечу.