В этом руководстве я объясню, как создать гибкую систему инвентаря-сетки для вашей игры, которая будет похожа на те, что есть в Deus Ex, S.T.A.L.K.E.R. и Pathologic 2. Вы сможете с нуля написать эту систему пошагово, либо, если у вас уже есть игра, внедрить ее в свой код. Я постарался написать это руководство максимально подробно, так что в нем будут затронуты некоторые принципы работы самого движка, но даже если вы используете не GameMaker, а другой движок, статья все равно может быть вам полезна.

Перед началом
Для старта понадобится объект игрока и какой-нибудь другой объект, пусть это будет сундук (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-объектом, то есть он не должен уничтожаться при смене комнат, а должен существовать с момента создания и до закрытия игры.


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

В событии 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-файл, в котором описаны 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
В этом скрипте будут все функции для работы с сеткой инвентаря. На каждую функцию я оставил подробные комментарии.
Создаем сетку инвентаря
//создание инвентаря размерами _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; //предмет успешно добавлен
}
Находим координаты основной ячейки через побочную. Если передаем в качестве аргументов координаты основной ячейки, возвращаем ее же
//находим основную ячейку предмета по указанным координатам
//возвращает:
//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 = 0×0).

После отрисовки содержимого ячейки отрисовываем количество предметов в ней. Количество будет находиться в правом нижнем углу предмета.
Теперь применим эту функцию в 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
Добавить контекстное меню для взаимодействия с предметами (яблоко — съесть, коробку — вскрыть, броню — экипировать и т. д.)
Возможно, я напишу продолжение этой статьи, где объясню, как реализовать эти идеи.
Скачать исходники проекта можно здесь, он скомпилирован на версии v2024.13.1.193 (Steam).
Спасибо за внимание! Если есть вопросы - пишите в комментариях, на все отвечу.