Как стать автором
Поиск
Написать публикацию
Обновить

Система сетчатого инвентаря в игре на GameMaker

Уровень сложностиПростой
Время на прочтение23 мин
Количество просмотров936

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

порядок смены комнат
порядок смены комнат
свойства oGameManager
свойства 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-файл, в котором описаны 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).

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

Теги:
Хабы:
+3
Комментарии0

Публикации

Ближайшие события