Система сетчатого инвентаря в игре на GameMaker
В этом руководстве я объясню, как создать гибкую систему инвентаря-сетки для вашей игры, которая будет похожа на те, что есть в 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, которая хранит пары ключ-значение. В нашем случае ключ - это идентификатор предмета (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-файла. Она будет выглядеть так:
Функция 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().
Добавим в этот же скрипт следующую функцию:
Эта функция будет принимать идентификатор предмета и возвращать его структуру. Если мы передаем несуществующий идентификатор, функция вернет undefined.
В событии Create объекта oGameManager перед строкой room_goto(rTest); добавим следующую строчку:
Она запустит функцию, которая проинициализирует 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
В этом скрипте будут все функции для работы с сеткой инвентаря. На каждую функцию я оставил подробные комментарии.
Создаем сетку инвентаря
Уничтожаем сетку инвентаря
Получаем размеры предмета в клетках (ячейках). Здесь мы используем функцию getItemFromGlobalDatabase(_itemID), чтобы найти предмет по его id
Функция, проверяющая возможность размещения предмета по указанным координатам. Аргументы _cellX и _cellY - координаты основной ячейки. Мы проверяем все ячейки, которые будет занимать предмет, отталкиваясь от координат основной. Таким образом, если мы, например, размещаем предмет размером 2x2 в ячейке (3;4), то функция вернет true только в том случае, если ячейки (3;4), (4;4), (3;5) и (4;5) будут свободны
Функция, добавляющая предмет в инвентарь при условии, что все ячейки, необходимые для добавления, свободны. Здесь нет проверки, является ли добавляемый предмет таким же, что и находящийся в ячейке, поскольку логика стакования будет позже прописана в другом месте. Также здесь нет проверки, является ли значение _quantity больше, чем максимально возможное количество предметов такого типа в одной ячейке - на случай, если в качестве исключения вам захочется добавить больше предметов в одну позицию, чем это может сделать игрок
Находим координаты основной ячейки через побочную. Если передаем в качестве аргументов координаты основной ячейки, возвращаем ее же
Функция, возвращающая структуру предмета из global.ItemDB через основную ячейку предмета. Для нахождения основной ячейки используем предыдущую функцию
Функция, принимающая координаты ячейки и удаляющая предмет в этой ячейке через основную. Для этого также используем функцию inventoryGetMainItemCell(_inventoryGrid, _cellX, _cellY). Здесь мы проходимся по всем ячейкам этого предмета и присваиваем им noone, что в дальнейшем будет сигнализировать о том, что ячейки пустые
Наш инвентарь будет отображаться как элемент интерфейса в виде сетки в левом верхнем (или любом другом) углу, поэтому нам понадобится функция, переводящая экранные координаты (в пикселях) в координаты сетки (в ячейках), чтобы мышью управлять инвентарем: при наведении мыши на инвентарь мы будем получить координаты той ячейки, над которой она “висит”. Самой обработки движений и нажатий мыши здесь еще нет, ее логика будет прописана в другом месте. Функция принимает в качестве аргументов сам инвентарь, координаты точки в пикселях, координаты начала инвентаря и размер ячейки. Так как функция учитывает расположение сетки инвентаря, саму сетку мы сможем размещать как угодно (об отрисовке сетки позже), и нам не нужно будет менять или дополнять код в этой функции
Создание инвентарей и добавление предметов в них
Теперь мы можем добавлять инвентари объектам. В событии Create любого объекта, который вы хотите наделить инвентарем, пишите следующую строчку:
где _width и _height - ширина и высота сетки соответственно.
И обязательно в событии Clean Up необходимо добавить эту строку:
Без этой строки при уничтожении экземпляра объекта сетка продолжит существовать, а так как переменная, ссылающаяся на нее (inventory), будет удалена вместе с экземпляром объекта, получить доступ к этой сетке вы больше не сможете, и это приведет к утечке памяти.
Для добавления предметов в инвентарь, необходимо использовать функцию inventoryAddItemTo(_inventoryGrid, _itemID, _quantity, _cellX, _cellY).
Для инвентаря объекта игрока я добавлю в событии Create следующие строки:
Также создадим инвентарь для сундука и добавим туда несколько предметов:
Менеджер инвентаря
Теперь нам необходим объект, который будет отвечать за отрисовку интерфейса и обработку пользовательских нажатий относительно инвентаря. Назовем этот объект oInventoryManager. Создание такого менеджера поможет отделить код для вышеперечисленного функционала от всей остальной игры, что позволит при необходимости с легкостью его отредактировать или деактивировать без путаницы и последствий. Этот менеджер будет обрабатывать как инвентарь игрока в одиночку, так и два инвентаря одновременно, например, для лутинга сундука (или торговли/бартера между игроком и NPC - при соответствующей доработке этой системы).
Создаем oInventoryManager. НЕ делаем его Persistent: он будет существовать только когда мы открываем инвентарь, а в момент закрытия он будет удаляться. В событии Create напишем такой код:
Здесь уже есть комментарии, объясняющие, что эти переменные и функция делают.
Теперь в событии Destroy напишем следующее:
Без этих строк при закрытии инвентаря предмет будет просто исчезать.
Теперь нам понадобятся функции, которые будут отвечать за создание/уничтожение oInventoryManager. Эти функции мы не будем писать в самом oInventoryManager, потому что в таком случае мы не сможем вызвать функцию создания этого объекта (его же еще не существует), так что создадим скрипт scInventoryManager и напишем там следующее:
Здесь описаны три функции:
- inventoryManagerCreateSingle() создает менеджер инвентаря, инициализируя переменную invPlayer инвентарем игрока и оставляя переменную invOther равной noone, что будет означать, что второго инвентаря, с которым мы бы взаимодействовали, нет - то есть игрок просто открывает свой инвентарь
- Функция inventoryManagerCreateDouble(_invOther = noone) служит для создания менеджера инвентаря с присвоением invOther какого-нибудь другого инвентаря, с которым бы взаимодействовал игрок. Здесь, если мы передаем в качестве аргумента какой-то конкретный инвентарь, то менеджер будет создаваться с ним, а если нет, то игра проверит наличие сундука в комнате и найдет ближайший
Обратите внимание, что в этой функции фигурирует константа InteractionDistance - я ее объявил в событии Create объекта oGameManager следующим образом:
Это максимальная дистанция от объекта игрока до объекта, с которым он пытается взаимодействовать. Так вот, если ближайший к игроку сундук находится на расстоянии меньше, чем максимально возможное, то этот сундук и является тем, который будет обрабатываться oInventoryManager.
- Третья функция просто удаляет oInventoryManager при условии, что он существует
Условия, при которых будут вызываться эти функции, могут быть разными:
Вы добавили игроку в инвентарь квестовый предмет и сразу хотите это продемонстрировать, открыв его - вызываете inventoryManagerCreateSingle()
Игрок нажал на “Бартер” в диалоге с NPC - вызываете inventoryManagerCreateDouble(_invOther), где _invOther - его инвентарь
Вы, управляя игроком, подошли к сундуку и нажали на E - вызываете inventoryManagerCreateDouble() (без аргумента)
- Вы, управляя игроком, нажали на I - вызываете inventoryManagerCreateSingle()
Давайте реализуем функционал последних двух способов.
Обработку нажатий клавиш я советую осуществлять в событии Step отдельного объекта - oInputHandler. Создайте его, разместите в rInit (инициализирующей комнате) и сделайте Persistent.
Добавьте в Create такие строки:
Это будут кнопки, отвечающие за открытие инвентаря. В событии Step напишите:
Теперь при нажатии на I или E менеджер инвентаря будет создаваться, а при нажатии на Escape - уничтожаться. Чтобы открыть сундук, нужно подойти к нему на достаточное расстояние.
Подробнее об oInputHandler я напишу отдельную статью. Когда она будет опубликована, здесь появится ссылка на нее.
Визуальное отображение и обработка нажатий инвентаря
Начнем с кода для отрисовки сетки инвентаря, но сперва нужно создать какой-нибудь шрифт. Если у вас еще ни одного шрифта в проекте нет, можете создать обычный Arial.
В событии Draw GUI добавьте draw_set_font(font);, где font - название вашего шрифта. В данном случае это fArial.
Далее создадим функцию для отрисовки самой сетки инвентаря и предметов, находящихся в ней. Разместим объявление этой функции в событии Create, в самом конце:
Функция в качестве аргументов принимает инвентарь, который нужно отрисовать, и левый верхний угол этого инвентаря. Эту функцию мы будем вызывать каждый кадр игры в Draw GUI для инвентаря игрока и при необходимости для инвентаря, с которым взаимодействует игрок. Давайте разберем этот код по частям:
Сначала мы рисуем саму сетку, по ячейкам. Функция draw_rectangle(drawX, drawY, drawX + cellSize, drawY + cellSize, false); рисует черную ячейку, а draw_rectangle(drawX, drawY, drawX + cellSize, drawY + cellSize, true); рисует обводку для этой ячейки.
Затем отрисовываем предметы. Для этого проходимся по всему ds_grid и ищем элементы, являющиеся структурами и содержащие поле itemID - это левые верхние углы предметов. Элементы, являющиеся ссылками на основную ячейку и пустыми ячейками, разумеется, пропускаем. Так как мы начинаем отрисовку с левого верхнего угла предмета, то и точка привязки спрайта должна находиться в левом верхнем углу. Убедитесь, что для каждого спрайта вы выбрали точку привязки Top Left (Origin = 0x0).
После отрисовки содержимого ячейки отрисовываем количество предметов в ней. Количество будет находиться в правом нижнем углу предмета.
Теперь применим эту функцию в Draw GUI, а также добавим надписи сверху, чтобы понимать, где инвентарь игрока, а где другой:
Теперь попробуем запустить игру. При открытии сундука видим такой результат:
Сейчас с этими инвентарями мы ничего не можем сделать, поэтому напишем код для перетаскивания предметов.
В событии Step все того же oInventoryManager напишем такие строки:
В начале события стоит проверка, двигается ли объект игрока. Если да, то закрываем инвентарь. В моем случае движение игрока определяется переменной dx: если она не равна 0, то игрок находится в движении. У вас эта проверка может происходить по-другому или ее вообще может не быть.
Затем смотрим на расположение курсора и проверяем, заходит ли он на какой-нибудь из инвентарей. Если да, сразу ищем ячейку, над которой “висит” курсор, используя ранее написанную функцию для перевода экранных координат в координаты инвентаря.
Если курсор за пределами сеток инвентаря, дальнейшее выполнение события не имеет смысла, поэтому останавливаем его.
Теперь напишем саму логику перетаскивания. Оно будет осуществляться с помощью нажатий левой кнопки мыши:
Теперь добавим в Draw GUI код для отрисовки перетаскиваемого предмета, сразу после кода для отрисовки сеток:
Сначала рисуем проекцию перетаскиваемого предмета. Это белые полупрозрачные квадраты, которые рисуются в том месте, где расположится предмет при нажатии на ЛКМ.
Затем отрисовываем перетаскиваемый предмет. Он будет полупрозрачным и перемещаться вместе с курсором.
Итог
Что дальше?
Вот идеи для доработки этой системы:
- Добавить возможность выбирать, сколько предметов перетаскивать (сейчас можно только перетаскивать весь стак)
Добавить функцию для добавления предмета в свободное пространство инвентаря без указания конкретных координат. Она может понадобиться, например, для получения квестовых предметов
- Для идеи, описанной выше, добавить обработку ситуации, когда для предмета не хватает места, например, выбросить его на землю
- Добавить функцию для удаления предмета из инвентаря не по координатам, а по его ID
- Добавить контекстное меню для взаимодействия с предметами (яблоко - съесть, коробку - вскрыть, броню - экипировать и т.д.)
Возможно, я напишу продолжение этой статьи, где объясню, как реализовать эти идеи.
Скачать исходники проекта можно здесь, он скомпилирован на версии v2024.13.1.193 (Steam).
Спасибо за внимание! Если есть вопросы - пишите в комментариях, на все отвечу.