Система сетчатого инвентаря в игре на GameMaker
В этом руководстве я объясню, как создать гибкую систему инвентаря-сетки для вашей игры, которая будет похожа на те, что есть в Deus Ex, S.T.A.L.K.E.R., Pathologic и др. Вы сможете с нуля написать эту систему пошагово, либо, если у вас уже есть игра, внедрить ее в свой код. Я постарался написать это руководство максимально подробно, так что в нем будут затронуты некоторые принципы работы самого движка, но даже если вы используете другой движок, статья все равно может быть вам полезна.
Перед началом
Для старта понадобится объект игрока (я назвал его oPlayer) со спрайтом.
Также создадим объект контейнера (я назвал его oContainer). oContainer будет любым объектом окружения, который вы захотите наделить инвентарем. Это может быть мусорное ведро, ящик, рюкзак и т.д. Оставляем этот объект без спрайта по умолчанию: для каждого экземпляра, размещенного в комнате, можно будет настроить свой спрайт в меню слева при нажатии на экземпляр объекта.
Подготовим несколько спрайтов на тест для визуализации предметов инвентаря. В рамках данного руководства размер одной ячейки в сетке равен 32 на 32 пикселя, так что спрайты предметов размером 1 на 1 клетку будут иметь размер 32x32, предметов 1x2 — 32x64 и т.д. spItemError нужен на случай ошибки загрузки предмета.
spApple — 32x32;
spItemError — 32x32;
spMysteriousPackage — 64x64;
spWaterBottle — 32x64.
Еще нужна комната, где мы будем тестировать систему инвентаря, я назову ее rTest.
Создайте в комнате два слоя объектов (Instance Layer), назовите один “Instances”, а другой “UI”. На слое Instances мы будем размещать экземпляры объекта игрока и объекта контейнера, а на слое UI будем отрисовывать инвентарь.
В комнате на слое Instances нужно расставить экземпляры объектов игрока и контейнера (контейнеру добавьте какой-нибудь спрайт, как показано на скриншоте выше).
Инициализация глобальных переменных для работы с инвентарем
Нам понадобятся некоторые глобальные переменные для работы системы инвентаря. Безопасной практикой объявления глобальных переменных считается их объявление сразу после запуска игры. Есть несколько способов объявить глобальные переменные:
- В отдельной “стартовой” комнате, в коде создания комнаты (Room Creation Code);
- В отдельном объекте-менеджере, размещенном в самой первой комнате и самым первым в списке очереди создания экземпляров объектов;
- В отдельном скрипте, который в свою очередь может быть вызван как в Room Creation Code, так и в событии Create объекта-менеджера.
Я рекомендую объединить первые два пункта: создать отдельную комнату (rInit), которая будет самой первой при запуске, и разместить туда объект-менеджер (oGameManager), но только без использования Room Creation Code. oGameManager должен быть Persistent-объектом, то есть он не должен уничтожаться при смене комнат, а должен существовать с момента создания и до закрытия игры.
Сейчас это единственный экземпляр в комнате, но в дальнейшем при добавлении других объектов он должен быть первым в списке очереди инициализации.
В событии Create напишем следующее:
global.inventoryDebugMode — это переменная, при истинном значении которой у нас будет активен дебаг-режим. С помощью него мы будем отслеживать работоспособность системы инвентаря;
#macro InteractionDistance 100 — этой строкой мы создаем константу, которая описывает максимально возможное расстояние от игрока до объекта, у которого есть инвентарь, для взаимодействия с ним;
global.ItemDB — это список всех предметов, существующих в игре, представленный в виде структуры (struct), внутри которой будут лежать отдельные структуры, описывающие конкретные предметы;
room_goto(index) перебрасывает нас в нужную комнату.
Факт смены комнаты не помешает созданию следующих в очереди экземпляров (тех, что вы, возможно, позже разместите ниже oGameManager в меню, которое выделено на скриншоте), потому что функция room_goto(index) не сразу меняет комнату, а только по завершению всех событий текущего кадра игры. Например, сразу после oGameManager у меня стоит Persistent-объект oInputHandler, который управляет обработкой нажатий клавиш, и он успешно создается перед сменой комнаты.
json-файл
Создадим json-файл, в котором будут описаны предметы, которые мы будем загружать в память при запуске игры. Для теста я использую такой json-файл, в котором описаны 3 разных предмета.
Каждый предмет здесь представлен в виде структуры из 7 полей:
Name — имя предмета (не то же самое, что и id)
Type — тип предмета (снаряжение, еда, оружие и т.д.)
Width, Height — размеры предмета в ячейкахMaxStack - максимальное количество предметов в одном стаке
Sprite — название спрайта (который вы создали в самом GameMaker - spApple, spWaterBottle и т.д.) предмета
Description — описание предмета
Вы можете убрать часть полей или добавить новые.
Этот файл нужно добавить в папку datafiles вашего проекта. Ее можно быстро открыть прямо из GameMaker’а.
Скрипт scItemDatabase
Создаем скрипт scItemDatabase, он будет предназначен для работы с самой базой данных предметов. В нем объявим функцию loadItemDefinitions, которая будет инициализировать global.ItemDB, считывая данные о предметах из json-файла. Она будет выглядеть так:
Выражение global.ItemDB[$ _id] означает, что мы обращаемся к элементу этого списка с ключом _id (предмету с идентификатором _id). Выражение global.ItemDB[_id] в данном случае привело бы к ошибке при компиляции, потому что компилятор думал бы, что вы пытаетесь обратиться не к структуре, а к обычному массиву.
Иными словами, конструкция [$ _id] — это акцессор (accessor), который нужен для быстрого доступа к определенному элементу, но для разных структур данных есть свои акцессоры. Например, для ds_grid, которую мы чуть позже будем использовать, акцессор выглядит так: [# i, j].
Добавим в этот же скрипт следующую функцию:
Эта функция будет принимать идентификатор предмета и возвращать его структуру. Если мы передаем несуществующий идентификатор, функция вернет undefined.
В событии Create объекта oGameManager перед строкой room_goto(rTest); добавим следующую строчку:
Она запустит функцию, которая проинициализирует global.ItemDB.
На этом работа с парсингом json-файла и инициализации global.ItemDB закончена. Следующим этапом будет создание самого инвентаря.
Общие принципы работы инвентаря
- Инвентарями мы сможем наделять как объект игрока, так и другие объекты (сундук, мусорное ведро на улице, другой NPC и т.д.);
- Так как инвентарь, который мы создаем, будет сетчатым, мы воспользуемся встроенной в GameMaker структурой данных ds_grid, которая, по сути, является двумерным массивом с некоторыми улучшениями для упрощения работы. Наш инвентарь и будет являться контейнером ds_grid, просто мы напишем дополнительные функции для работы с ним;
- Предметы, размещаемые в сетке, могут иметь разные размеры (1x1, 1x2, 2x2 и т.д.), нам нужно это учитывать при добавлении, перемещении и удалении этих предметов. В структуре предмета есть поля, отвечающие за его размер (Width и Height);
- В одной и той же ячейке может находиться несколько предметов одного типа, но не более, чем значение поля MaxStack структуры предмета.
Учитывая все перечисленное, составим примерное описание того, как будет выглядеть хранение предметов в инвентаре:
- Левая верхняя (основная) ячейка предмета в инвентаре — структура, содержащая поля itemID и quantity. Обращаясь к какому-либо предмету в инвентаре, мы будем обращаться именно к основной ячейке этого предмета, тем самым получать его идентификатор и количество таких предметов в этой ячейке, а так как мы знаем идентификатор, то можем и узнать всю информацию об этом предмете через функцию getItemData(_itemID);
- Все остальные ячейки предмета инвентаря, кроме основной — структуры со значениями refX и refY, которые являются координатами основной ячейки. Через побочные ячейки мы будем получать доступ к основной;
- Если ячейка в сетке инвентаря ничем не занята, она будет принимать значение noone.
Скрипт scInventory
Создадим скрипт scInventory. В этом скрипте будут все функции для работы с сеткой инвентаря. На каждую функцию я оставил подробные комментарии. Если при чтении кода вы заметили не определенную функцию, значит, она определена позже в этом или другом скрипте.
Создаем инвентарь
Уничтожаем инвентарь
Проверяем, пуст ли инвентарь
Функция, проверяющая возможность размещения предмета по указанным координатам. Аргументы _cellX и _cellY — координаты основной ячейки. Мы проверяем все ячейки, которые будет занимать предмет. Таким образом, если мы, например, размещаем предмет размером 2x2 в ячейке (3;4), то функция вернет true только в том случае, если ячейки (3;4), (4;4), (3;5) и (4;5) будут свободны
Функция, проверяющая наличие предмета в инвентаре в указанном количестве
Функция, ищущая место для размещения предмета
Функция для расширения инвентаря
Находим координаты основной ячейки через побочную. Если передаем в качестве аргументов координаты основной ячейки, возвращаем ее же
Функция, возвращающая данные ячейки
Функция, добавляющая предмет в инвентарь по указанным координатам. Здесь прописана логика как для стакования, так и для добавления предмета в пустую ячейку
Добавляем предмет в инвентарь без указания конкретных координат. Сначала пытаемся заполнить существующие стаки, затем раскладываем оставшиеся предметы по пустым ячейкам. Если места не хватило, выбрасываем оставшиеся предметы
Функция, принимающая координаты ячейки и удаляющая предмет в этой ячейке через основную. Здесь мы проходимся по всем ячейкам этого предмета и присваиваем им noone, что в дальнейшем будет сигнализировать о том, что ячейки пустые
Удаляем предмет из инвентаря без указания конкретных координат. Если нужного количества предметов для удаления не нашлось, сразу возвращаем false
Наш инвентарь будет отображаться как элемент интерфейса в виде сетки в левом верхнем (или любом другом) углу, поэтому нам понадобится функция, переводящая экранные координаты (в пикселях) в координаты сетки (в ячейках), чтобы мышью управлять инвентарем: при наведении мыши на инвентарь мы будем получить координаты той ячейки, над которой она “висит”. Самой обработки движений и нажатий мыши здесь еще нет, ее логика будет прописана в другом месте. Функция принимает в качестве аргументов сам инвентарь, координаты точки в пикселях, координаты начала инвентаря и размер ячейки. Так как функция учитывает расположение сетки инвентаря, саму сетку мы сможем размещать как угодно (об отрисовке сетки позже), и нам не нужно будет менять или дополнять код в этой функции
Функция для отрисовки указанного инвентаря по указанным координатам
Скрипт scItem
Функция для выбрасывания предмета. Предмет выбрасываем в мешок oBag. Если мешок уже есть рядом с игроком, выбрасываем в него. Если мешка нет, создаем его
объект oBag (мешок) пока не определен, мы создадим его чуть позже, пока просто представьте, что он существует
Функция, возвращающая размеры предмета
Создание инвентарей и добавление предметов в них
Теперь мы можем добавлять инвентари объектам. В событии Create объекта игрока напишите следующую строчку:
Замените _width и _height на значения, соответствующие желаемым размерам инвентаря.
И обязательно в событии Clean Up необходимо добавить эту строку:
Без этой строки при уничтожении экземпляра объекта сетка продолжит существовать, а так как переменная, ссылающаяся на нее (inventory), будет удалена вместе с экземпляром объекта, получить доступ к этой сетке вы больше не сможете, и это приведет к утечке памяти.
Для oContainer объявим инвентарь другим способом — в разделе Variable Definitions. Также добавим булеву переменную isPersistent, по умолчанию равной True — она будет нужна для проверки, надо ли удалять контейнер, когда он опустошается. Чуть позже мы напишем код, который будет автоматически удалять из комнаты все пустые контейнеры, у которых isPersistent равен False.
И, точно так же, как и для объекта игрока, для oContainer создайте событие Clean Up и в ней напишите функцию удаления инвентаря:
Для каждого отдельного экземпляра объекта контейнера в комнате вы можете переопределить переменную inventory, определенную в Variable Definitions, то есть изменить размер инвентаря. Я сделаю инвентарь контейнеру размером 7x3.
Создадим объект oBag. Это тот самый мешок, в который будут выбрасываться предметы, которые не влезли в инвентарь при попытке добавления. Нужно установить ему родителя oContainer, чтобы он унаследовал его переменные. Добавьте ему небольшой спрайт, чтобы его было видно. В окне Variable Definitions установите переменную isPersistent равной False, чтобы когда игрок забирал все предметы из этого мешка, мешок сам уничтожался (код для этого напишем чуть позже).
Для добавления предметов в инвентарь, необходимо использовать ранее написанную функцию inventoryAddItemTo(_inventoryGrid, _itemID, _quantity, _cellX, _cellY) или inventoryAddItem(_inventoryGrid, _itemID, _quantity).
Для инвентаря объекта игрока я добавлю в событии Create следующие строки:
Также создадим инвентарь для сундука и добавим туда несколько предметов
Менеджер инвентаря
Теперь нам необходим объект, который будет отвечать за отрисовку интерфейса и обработку пользовательских нажатий относительно инвентаря. Этот менеджер будет обрабатывать как инвентарь игрока в одиночку, так и два инвентаря одновременно, например, при лутинге сундука.
Создаем oInventoryManager. НЕ делаем его Persistent: он будет существовать только когда мы открываем инвентарь, а в момент закрытия он будет удаляться. В событии Create напишем такой код:
Теперь в событии Destroy напишем следующее:
Без строк, заключенных в первые фигурные скобки, при закрытии инвентаря перетаскиваемый предмет будет просто исчезать. После них прописана логика удаления временных контейнеров.
Теперь нам понадобятся функции, которые будут отвечать за создание/уничтожение oInventoryManager. Эти функции мы не будем писать в самом oInventoryManager, потому что в таком случае мы не сможем вызвать функцию создания этого объекта (его ведь еще не существует.), так что создадим скрипт scInventoryManager и напишем там следующее:
Визуальное отображение и обработка нажатий инвентаря
Визуальное отображение инвентаря мы будем реализовывать в событии Draw GUI менеджера инвентаря oInventoryManager, а обработку нажатий — в событии Step.
Но сперва нужно создать какой-нибудь шрифт. Если у вас еще нет ни одного шрифта в проекте, можете создать обычный Arial.
Функцию для отрисовки сетки инвентаря и предметов, находящихся в ней, мы написали ранее в скрипте scInventory (inventoryDraw(_inventory, _startX, _startY)). Эта функция рисует как саму сетку, так и все предметы, находящиеся в инвентаре.
Нам необходимо вызывать эту функцию дважды: для инвентаря игрока и инвентаря другого объекта, с которым взаимодействуем (если он существует).
Также нужно отрисовать перетаскиваемый предмет.
В конце добавим код для дебага: рядом с курсором будут отображаться значения некоторых переменных для теста.
Вот, что получится:
Сейчас с этими инвентарями мы ничего не можем сделать, поэтому напишем код для перетаскивания предметов.
В событии Step все того же oInventoryManager напишем такие строки:
Здесь мы сначала смотрим на расположение курсора и ищем ячейку, над которой “висит” курсор, используя ранее написанную функцию для перевода экранных координат в координаты инвентаря, после чего определяем инвентарь, над которым “висит” курсор.
Если курсор за пределами сеток инвентаря, дальнейшее выполнение события не имеет смысла, поэтому останавливаем его.
Далее пишем саму логику перетаскивания. Оно осуществляется с помощью нажатий левой кнопки мыши.
Последний штрих
Предполагаю, что передвижение игрока вы уже реализовали сами. Тогда последний штрих перед запуском — обработка нажатия клавиши открытия инвентаря. В событии Step объекта oPlayer напишем:
При нажатии на “I” мы будем проверять наличие менеджера инвентаря. Если он не создан, то создаем его. Если он уже был создан, уничтожаем его.
После создания ищем контейнер рядом с нами. Если не находим, то просто открываем инвентарь.
Итог
Запускаем проект и видим результат.
Теперь давайте проверим, как работает oBag. Создадим ситуацию, при которой происходит попытка добавления предмета в переполненный инвентарь игрока. Для этого добавим следующие строки в Step Event объекта oPlayer:
При запуске видим, что те предметы, которые не смогли добавиться в инвентарь, были выброшены в мешок рядом с игроком. При его открытии видим, что в мешке лежат две коробки.
Может быть проблема, когда все спрайты предметов в инвентарях становятся равными spItemError. Это происходит потому, что спрайты spApple, spWaterBottle и spMysteriousPackage явно не упоминаются в самом коде игры (на этапе компиляции), в результате чего движок думает, что эти спрайты вообще не используются, и удаляет их из игры для оптимизации. Решается это следующим образом:
Проект
Скачать исходники проекта можно здесь, он создан на версии v2024.14.3.260 (Steam).