В этом руководстве я объясню, как создать гибкую систему инвентаря-сетки для вашей игры, которая будет похожа на те, что есть в 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).
Спасибо за внимание! Если есть вопросы - пишите в комментариях, на все отвечу.
Автор: mortetheskull
