Managed Plugins
Обычно скрипт в проекте содержится как файл исходного текста и компилируется Юнити при изменении. Однако также возможно компилировать скрипт в динамически связываемую библиотеку (dll) используя внешний компилятор. Результирующая dll может быть затем добавлена к проекту, а содержащиеся в ней классы прикреплены к объекту как обычные скрипты.
Намного легче работать со скриптами, чем с dll. Однако вы можете иметь доступ к коду поставляемому третьей компанией в форме dll. Когда вы разрабатываете свой собственный код, вы можете использовать компилятор не поддерживаемый в Юнити (например F#) скомпилировав код в dll и добавив его к проекту Юнити. Также вы можете захотеть распространять свой Юнити код без исходника (для продажи на АссетСторе, например), а dll — легкий путь сделать это.
Создание dll
Для создания dll вам нужен сначала подходящий компилятор. Не все компиляторы, которые производят .NET код гарантированно работают с Юнити, так что стоит проверить компилятор с каким-то доступным кодом, прежде чем делать значительную работу с ним. Если dll не содержит код, обращающийся к АПИ Юнити,- вы можете просто скомпилировать его в dll используя соответствующие опции компилятора. Если вы хотите использовать АПИ Юнити, вам нужно сделать dll движка Юнити доступными для компилятора. На Мак они содержатся в пакете приложения (вы можете увидеть внутреннюю структуру пакета командой Показать содержимое Пакета (Show Package Contents) из контекстного меню — правым щелчком или Ctrl-click на приложении Юнити):-
Путь к dll Юнити обычно такой
/Applications/Unity/Unity.app/Contents/Frameworks/Managed/
На Виндовс dll могут быт myfqltys в папках, принадлежащих приложению Юнити. Путь обычно такой
C:\Program Files\Unity\Editor\Data\Managed
Точные опции для компилирования dll сильно завися от используемого компилятора. Например командная строка для Моно C# компилятора mcs на Мак ОС может выглядеть так:-
mcs -r:/Applications/Unity/Unity.app/Contents/Frameworks/Managed/UnityEngine.dll -target:library ClassesForDLL.cs
Использование dll
После компиляции dll файл может быт просто перетащен в Юнити проект как и любой другой ассет. Dll ассет имеет треугольник раскрывающий содержимое файла для выбора отдельного класса внутри библиотеки. Классы, которые были унаследованы от MonoBehaviour могут быть перетащены на ГеймОбъект как обычные скрипты. Не MonoBehaviour классы могут быт использованы прямо из других скриптов обычным путем.
Пошаговое руководство для MonoDevelop и Visual Studio
В этой секции вы узнаете как собрать и интегрировать простую dll с использованием MonoDevelop и Visual Studio, и как подготовить отладочную сессию для этой DLL.
Настройка проекта
Сначала, откройте MonoDevelop или Visual Studio и создайте новый проект. В MonoDevelop, это делается открытием File > New > Solution и затем выбором C# > Library . В Visual Studio, вы должны открыть File > New > Project и затем выбрать Visual C# > Class Library .
Затем нужно заполнить информацию о новой библиотеке:
- Name это пространство имен (в этом примере, используйте “DLLTest” в качестве названия).
- Location это корневая папка проекта.
- Solution name это папка проекта (название solution).
Далее вы должны добавить ссылки на DLL библиотеки Unity. В MonoDevelop, вы должны открыть контекстное меню для Ссылок в Solution Browser(Браузере Решений) и выбрать Edit References . Теперь выберите опцию .Net Assembly tab > File System > select file . В Visual Studio, откройте контекстное меню для Ссылок в Solution Explorer и выберите Add Reference . Затем, выберите опцию Browse > Browse > select file .
На данном этапе, у вас будет возможность выбрать нужный DLL файл. В Mac OSX, файл можно найти в
Applications/Unity.app/Contents/Frameworks/Managed/UnityEngine.dll Program Files\Unity\Editor\Data\Managed\UnityEngine.dll
Для этого примера, переименуем класс в “MyUtilities” в Solution browser, и заменим его код на следующее:
using System; using UnityEngine; namespace DLLTest < public class MyUtilities < public int c; public void AddValues(int a, int b) < c = a + b; >public static int GenerateRandom(int min, int max) < System.Random rand = new System.Random(); return rand.Next(min, max); >> >
Закончив с кодом, соберите проект, и сгенерируйте DLL файл с отладочными символами.
Использование DLL в Unity
- Для этого примера, создайте новый проект в Unity, и скопируйте собранный файл /bin/Debug/DLLTest.dll в папку Assets. Затем, создайте C# скрипт под именем “Test” в Assets, и замените его содержимое на следующий код:
using UnityEngine; using System.Collections; using DLLTest; public class Test : MonoBehaviour < void Start () < MyUtilities utils = new MyUtilities(); utils.AddValues(2, 3); print("2 + 3 doc-menu">Add-in Manager > Installed tab > Unity > select Mono Soft Debugger Support for Unity > Enable .
После завершения настройки, вы сможете производить отладку кода использующего DLL, в Unity как обычно. Смотрите раздел Scripting Tools для получения дополнительно информации об отладке. …а две нужных dll называются UnityEngine.dll и UnityEditor.dll. …а имена dll такие же как на Мак ОС. Здесь опция -r указыват путь к библиотекам, включаемым в построение, в данном случае — Юнити библиотекам. Опция -target указывает какой тип построения требуется,- слово “library” используется для построения dll. Имя исходного файла для компилирования ClassesForDLL.cs (предполагается что этот файл в текущей рабочей папке, но вы можете указать полный путь если нужно). Если все пройдет хорошо результирующий dll файл появится в той же папке, что исходный. … в то время как на Windows, путь
Код
команду в командной строке, передав \bin\Debug\DLLTest.pdb в качестве параметра. Затем скопируйте преобразованный файл \bin\Debug\DLLTest.dll.mdb в Assets/Plugins.
Разработка компьютерной игры в Unity: начните здесь
Любой новый мир начинается с мечты. Unity предлагает все необходимое для ее воплощения. Здесь вы найдете множество советов по разработке вашей первой компьютерной игры. Никаких требований к уровню знаний и навыков: от вас нужно только желание!
Создавайте игру играючи
Начните творить на примере готовых шаблонов Unity Microgame. Каждый из шаблонов имеет свою коллекцию ресурсов Mod, позволяющих играючи изменить исходный шаблон, попутно осваивая основы игрового дизайна, логики взаимодействий, визуализации и многое другое.
LEGO® Microgame
Реализуйте свои творческие идеи с помощью виртуальных блоков LEGOⓇ в нашем новейшем шаблоне Microgame!
FPS Microgame
Взрывайте печеньки, добавляйте симпатичных, но смертоносных роботов, украшайте подземелье. Создайте собственный шутер от первого лица из шаблона FPS Microgame.
2D Platformer Microgame
Разбрасывайте конфетти, устройте феерию света, добавьте бодрости в походку вашего двумерного персонажа в этом милом платформере.
3D Karting Microgame
Набросайте мармеладных мишек, снопы искр и прокачайте свою тачку в веселом картинге.
Your first game jam with Unity
Каждому разработчику нужны единомышленники
Глобальное сообщество Unity предлагает участникам множество способов общения друг с другом. Для новичков доступны гейм-джемы, задачи и группы по интересам (по одной для шаблонов Karting, 2D Platformer и FPS Microgame), которые помогут набраться уверенности и поделиться своими первыми творениями. Мы рады всем желающим!
Made with Unity — Norman’s Island by Little Mountain Animation
Начните творить с Unity
Unity — это самая популярная в мире платформа разработки игр, ведь на ней создано более 50% всех мобильных игр, 60% всего контента для дополненной и виртуальной реальности, а Unity-разработчик — это седьмая по росту популярности профессия согласно недавнему отчету LinkedIn U.S. Emerging Jobs.
Новички могут загрузить Unity бесплатно и начать с готовых ресурсов Unity Microgame и Mod. Учитесь с помощью сотен обучающих материалов, курсов, словарей и игровых наборов — бесплатных или по разумной цене — от Unity и участников нашего потрясающего сообщества.
Вдохновляйтесь, учитесь и творите
Создайте двумерную компьютерную игру
Unity — это ведущая платформа разработки как 2D-, так и 3D-игр. Если вам больше по душе 2D, то здесь можно узнать, как разрабатывать такие игры.
Программирование компьютерной игры в Unity
Вы хотите узнать, как программировать игры? Мы предлагаем множество ресурсов, на примере которых вы сможете научиться программировать на C# в Unity.
Разработайте 3D-игру в Unity
Unity предлагает инструментарий, который поможет вам разработать вашу первую 3D-игру. Начните отсюда, если хотите познакомиться с процессом разработки нового иммерсивного мира для ваших игроков.
Sykoo Sam: начало разработки игр
Sykoo Sam — евангелист Unity в интернете, автор популярного канала, посвященного игровой разработке. Вот несколько советов разработчикам-новичкам.
Thomas Brush: посмотрите это, прежде чем создавать первую игру
Thomas Brush создает игры более 10 лет и готов поделиться мудростью, полезной как начинающим, так и опытным разработчикам.
Создайте 2D-игру в Unity
Узнайте, как создавать двумерные игры в Unity, используя карты плиток, спрайты, 2D-физику, анимацию и многое другое.
Made with Unity – Night in the Woods by Finji
Создавайте 2D-игры в Unity
Unity — самая популярная в мире платформа разработки двумерных и трехмерных игр: 50% всех мобильных игр созданы в Unity! Теперь пользователям доступна бесплатная версия Unity. Вы получите доступ к платформе разработки 2D-игр и к богатой базе бесплатных ресурсов, созданных нами и энтузиастами из нашего прекрасного сообщества.
Создайте двумерную игру в двумерной игре
После загрузки Unity вы получите возможность начать работу с изучения одного из наших готовых шаблонов Microgame, например 2D Platformer. К каждому шаблону Microgame прилагается коллекция Mod, увлекательных модификаторов, которые познакомят вас с важнейшими методами работы в Unity.
Unity Creator Kit: 2D RPG
Создайте 2D RPG всего за час!
Наборы Creator Kit — это простые игровые проекты, доступные на Unity Learn, которые можно освоить примерно за час. Набор 2D RPG Kit не затрагивает программирование, чтобы вы могли сосредоточиться на изучении Unity Editor и настройке игры в соответствии с вашими идеями и интересами.
The Explorer: 2D Kit by Unity
Разработка игрового процесса для 2D-игры
The Explorer: 2D — это коллекция игровой механики, инструментов, систем, графики и других ассетов для разработки двумерных игр. В набор включен пример игры, созданной с использованием этих систем. Этот проект можно освоить за пару часов, и вам не потребуется писать ни единой строчки кода.
Unity 2D game project: Ruby’s Adventure
Осваивайте инструменты для работы с 2D
Ruby’s Adventure — это проект, который познакомит начинающих разработчиков с процессом создания и программирования 2D-игры. В путешествии с Руби вы узнаете, как и зачем использовать спрайты, как создать первый скрипт, научитесь работать с инструментом Tilemap, украшать сцену, создавать эффекты частиц, пользовательский интерфейс, звуковое оформление и многое другое.
Советы по 2D-графике от сообщества Unity
Thomas Brush: советы по разработке 2D-игр
Канал Thomas Brush богат практическими советами и вдохновляющими идеями. Начните с видео 5 steps to making a gorgeous 2D game и Anyone can make 2D games!
Brackeys: Как создать 2D-игру
Brackeys — один из самых популярных и авторитетных каналов для Unity-разработчиков. Рекомендуем посмотреть серию, посвященную разработке 2D-игр в Unity.
Blackthornprod: руководства по текстурам и 2D-анимации
Blackthornprod создает видеоигры и делится различными советами на своем канале. Советуем взглянуть на его руководство по разработке персонажей, анимации, освещения, текстур и других элементов 2D-игр.
Как разрабатываются моды для Unity-игр: пишем свой мод
В этой части на примере мода для Beat Saber мы рассмотрим общие принципы разработки модов для Unity-игр, узнаем, какие есть трудности, а также познакомимся с Harmony — библиотекой для модификации кода игр, которая используется в RimWorld, Battletech, Cities: Skylines и многих других играх.
Хоть эта статья и похожа на туториал, как написать свой мод для Beat Saber, ее цель — показать, какие принципы используются при создании любых пользовательских модов и какие проблемы приходится решать при разработке. Все, что здесь описано, с некоторыми оговорками применимо для всех Unity-игр как минимум в Windows.
В предыдущей серии
Информация из первой части не нужна для понимания того, что будет происходить здесь, но все равно советую с ней ознакомиться.
Вот ее краткое (очень) содержание:
Программные моды (также известные как плагины) — это dll-библиотеки, которые загружаются вместе с игрой и выполняют какой-то код, добавляя в игру новую функциональность или модифицируя существующую. Если у игры нет встроенной поддержки модов, то никакие dll-файлы она запускать не будет. Поэтому для внедрения сторонних модов используются специальные библиотеки, например BepInEx или IPA. В Beat Saber используется BSIPA — улучшенная версия IPA. Сначала ее просто адаптировали специально для Beat Saber, а сейчас она в техническом плане значительно превосходит оригинальную IPA и может использоваться для любых Unity-игр.
Про Beat Saber и мод, который мы будем делать
Beat Saber является одной из самых популярных игр для VR-шлемов. Если у вас есть такой шлем, то, скорее всего, вы уже знаете, что такое Beat Saber. Если нет, то, возможно, вы видели хотя бы одно видео из игры в рекомендациях Youtube:
Давайте напишем мод, который показывает время в игре. Он будет показывать текущее время (обычные часы), количество минут, проведенных в игре с ее запуска, и количество минут, активно проведенных в игре, т.е. только время, проведенное в основном геймплее с размахиванием мечей и без учета времени в меню и на паузе.
В этой статье будет описана полная разработка мода, начиная с создания пустого проекта. Я разбил все на 5 шагов, в конце каждого шага будет краткий вывод об особенностях разработки модов. Если не хотите углубляться в код и детали, то можно просто пробежаться по выводам. Для полного понимания желательно знать основы Unity: работа со сценами, иерархия объектов, компоненты и их жизненный цикл.
Подготовка
Для начала нам нужно сделать так, чтобы игра была пригодна для модов. Для этого в случае с Beat Saber нужно скачать ModAssistant, настроить его (ничего сложного), установить обязательные моды вроде BSIPA, SongCore и BS_Utils и установить другие моды по вкусу. Теперь игра поддерживает моды, а в папках с игрой есть все нужные для нас библиотеки, и можно приступать к разработке.
В случае с другими играми нужно либо искать, что используется у них, либо читать мою прошлую статью про моды и добавлять поддержку модов самостоятельно.
Замечание про версии
Все, что написано в данной статье, работает как минимум для Beat Saber версии 1.9.1 и BSIPA версии 4.0.5. Все развивается и меняется, поэтому если вы читаете этот текст спустя какое-то время после его публикации, то имейте в виду, что часть информации может устареть.
Шаг 0: минимальный рабочий мод
Начнем с создания проекта и минимального набора сущностей, которые нужны, чтобы можно было добавить наш мод в игру и проверить, что он работает.
Начальные шаги неплохо написаны на сайте Beat Saber Modding Group (далее просто BSMG). К сожалению, только начальные шаги там и описаны. Там предлагается несколько шаблонов Visual Studio для создания проекта на выбор — просто берете, какой нравится и создаете проект из шаблона.
В этой статье мы пойдем более трудным путем и создадим проект с нуля. Берем любимую среду разработки для C# (у меня Rider), создаем новый C#-проект, выбираем Class Library в качестве целевой сборки и выбираем версию .NET, совместимую с Unity (у меня 4.7.2). Получаем пустой проект. Теперь создаем файлы мода.
manifest.json
Json-файл, содержащий мета-данные для BSIPA. Помечаем его в проекте как EmbeddedResource, чтобы при сборке он добавлялся внутрь нашего dll-файла.
$schema указывает на файл с описанием схемы для валидации формата. Файл лежит на GitHub в репозитории BSIPA. Нас это сильно волновать не должно, просто добавляем и забываем. В dependsOn указываем, какие сторонние моды мы используем в нашем собственном моде. BSIPA использует эту информацию, чтобы определить порядок загрузки dll-файлов. gameVersion и version используют семантическое версионирование.
Plugin.cs
Теперь создаем класс, который будет точкой входа для нашего плагина. В BSIPA 3 нужно было написать класс, реализующий интерфейс IBeatSaberPlugin. BSIPA 3 считывала все классы из dll-файла мода, находила там класс, реализующий интерфейс IBeatSaberPlugin, и создавала объект этого класса — так запускался мод. В BSIPA 4 убрали интерфейс IBeatSaberPlugin. Теперь BSIPA ищет класс, помеченный атрибутом [Plugin], и методы с атрибутами [Init], [OnStart] и [OnExit].
using IPA; using Logger = IPA.Logging.Logger; namespace BeatSaberTimeTracker < [Plugin(RuntimeOptions.SingleStartInit)] internal class Plugin < public static Logger logger < get; private set; >[Init] public Plugin(Logger logger) < Plugin.logger = logger; logger.Debug("Init"); >[OnStart] public void OnStart() < logger.Debug("OnStart"); >[OnExit] public void OnExit() < logger.Debug("OnExit"); >> >
Название класса может быть любое, но обычно его просто называют Plugin. Главное, чтобы пространство имен (namespace) соответствовало названию, которое мы указали в манифесте — в данном случае это BeatSaberTimeTracker. На этом этапе мы просто будем писать в лог, если был вызван какой-то метод.
Чтобы это собралось, нужно указать компилятору, где определены атрибуты [Plugin], [Init], [OnStart] и [OnExit]. Для этого в свойствах проекта добавляем в зависимости файл IPA.Loader.dll. Будем считать, что моды у нас уже внедрены в игру, а значит, все нужные библиотеки уже лежат в папке с Beat Saber где-то в папках Steam. Библиотеки игры, Unity, системные библиотеки и файлы IPA лежат в папке Beat Saber/Beat Saber_Data/Managed. Все просто добавляют файлы прямиком из папки Steam в проект и так и выкладывают на GitHub, тут нечего стесняться. BSMG сами советуют так делать.
Собираем наш мод, копируем получившийся dll-файл в папку Beat Saber/Plugins и запускаем игру. Для простой отладки не обязательно подключать VR-шлем, можно запустить игру из терминала с флагом fpfc. Игра запустится в режиме отладки с управлением мышью. Этого достаточно, чтобы потыкать кнопки в главном меню. После этого выходим из игры, идем в папку Beat Saber/Logs и ищем там логи для нашего мода.
[DEBUG @ 20:50:03 | BeatSaberTimeTracker] Init [DEBUG @ 20:50:03 | BeatSaberTimeTracker] OnStart [DEBUG @ 20:50:21 | BeatSaberTimeTracker] OnExit
Поздравляю, наш мод работает.
Вывод для шага 0
У любого мода должна быть точка входа. Это что-то типа аналога main в обычных программах. Детали реализации зависят от того, как именно работают моды: где-то нужно реализовать интерфейс, где-то использовать атрибуты или аннотации, а где-то просто добавить метод с определенным именем.
Шаг 1: выводим время на экран
На этом шаге сделаем так, чтобы мод делал что-то осмысленное, но еще не трогал код самой игры — добавим часы где-нибудь в углу и покажем время, проведенное в игре с ее запуска. Последуем принципу единственной ответственности и создадим новый класс TimeTracker. Класс Plugin нужен только для запуска и инициализации мода, никакой другой логики там быть не должно.
На этом этапе класс TimeTracker будет создавать canvas в мировом пространстве, добавлять на него два текстовых поля и раз в секунду обновлять на них значения.
Создаем объекты в Awake:
private void Awake() < Plugin.logger.Debug("TimeTracker.Awake()"); GameObject canvasGo = new GameObject("Canvas"); canvasGo.transform.parent = transform; _canvas = canvasGo.AddComponent(); _canvas.renderMode = RenderMode.WorldSpace; var canvasTransform = _canvas.transform; canvasTransform.position = new Vector3(-1f, 3.05f, 2.5f); canvasTransform.localScale = Vector3.one; _currentTimeText = CreateText(_canvas, new Vector2(0f, 0f), ""); _totalTimeText = CreateText(_canvas, new Vector2(0f, -0.15f), ""); >
Создаем объект, добавляем на него Canvas, настраиваем его, создаем два текстовых поля. Текстовые поля создаются в CreateText:
private static TextMeshProUGUI CreateText(Canvas canvas, Vector2 position, string text) < GameObject gameObject = new GameObject("CustomUIText"); gameObject.SetActive(false); TextMeshProUGUI textMeshProUgui = gameObject.AddComponent(); textMeshProUgui.rectTransform.SetParent(canvas.transform, false); textMeshProUgui.rectTransform.anchorMin = new Vector2(0.5f, 0.5f); textMeshProUgui.rectTransform.anchorMax = new Vector2(0.5f, 0.5f); textMeshProUgui.rectTransform.sizeDelta = new Vector2(1f, 1f); textMeshProUgui.rectTransform.transform.localPosition = Vector3.zero; textMeshProUgui.rectTransform.anchoredPosition = position; textMeshProUgui.text = text; textMeshProUgui.fontSize = 0.15f; textMeshProUgui.color = Color.white; textMeshProUgui.alignment = TextAlignmentOptions.Left; gameObject.SetActive(true); return textMeshProUgui; >
Этот метод выглядит громоздко, но, по сути, мы здесь просто создаем объект TextMeshProUGUI и выставляем параметры RectTransform, которые мы в обычном случае установили бы в редакторе Unity.
Тут мы подходим к одному серьезному ограничению при разработке модов для Unity-игр — у нас нет редактора Unity. У нас нет удобного графического интерфейса, и у нас нет сцены, на которой можно накидать все руками и сохранить в префаб — все нужно делать руками из кода. Из-за этого координаты объектов приходится подбирать экспериментально: пробуем какое-нибудь число, запускаем игру, смотрим в каком месте оказался текст. Меняем координаты, перезапускаем игру, смотрим. Повторять, пока текст не окажется там, где нужно.
Чтобы хотя бы примерно понимать, какие координаты должны быть у элементов интерфейса, я сначала вывел на экран 400 текстовых полей: сетку 20 на 20. В каждом поле я выводил его координаты. Это помогло мне начать хоть как-то ориентироваться в координатах и масштабе сцены.
В Update обновляем значения на текстовых полях:
private void Update() < if (Time.time >= _nextTextUpdate) < _currentTimeText.text = DateTime.Now.ToString("HH:mm"); _totalTimeText.text = $"Total: :"; _nextTextUpdate += TEXT_UPDATE_PERIOD; > >
Теперь обновляем наш класс Plugin, чтобы он создавал объект TimeTracker:
[OnStart] public void OnStart() < logger.Debug("OnStart"); GameObject timeTrackerGo = new GameObject("TimeTracker"); timeTrackerGo.AddComponent(); Object.DontDestroyOnLoad(timeTrackerGo); >
Чтобы наш объект жил долго и счастливо и не был убит сборщиком мусора, нужно либо прикрепить его к какой-нибудь существующей сцене в игре, либо вызвать DontDestroyOnLoad(…). Второй способ проще.
Чтобы все это работало, нам нужно добавить библиотеки Unity в список зависимостей проекта: UnityEngine.CoreModule.dll для GameObject и MonoBehaviour, UnityEngine.UI.dll и Unity.TextMeshPro.dll для TextMeshPro и UnityEngine.UIModule.dll для Canvas. Взять их можно все там же, в папке с игрой.
Собираем dll-файл, копируем его в папку с плагинами, запускаем игру и любуемся результатом.
[DEBUG @ 21:37:18 | BeatSaberTimeTracker] Init [DEBUG @ 21:37:18 | BeatSaberTimeTracker] OnStart [DEBUG @ 21:37:18 | BeatSaberTimeTracker] TimeTracker.Awake() [DEBUG @ 21:37:24 | BeatSaberTimeTracker] OnExit [DEBUG @ 21:37:25 | BeatSaberTimeTracker] TimeTracker.OnDestroy()
Все отлично, наш мод работает и уже даже приносит пользу. Пока что он живет своей жизнью — он не влияет на игру, а игра не влияет на него. Но из-за этого у нашего мода есть серьезная проблема: он показывает время всегда, даже если оно нам мешает. Например, в самом геймплее. С этим мы разберемся далее.
Вывод из шага 1
У нас нет исходных файлов игры, а значит, ее нельзя открыть в редакторе Unity и пользоваться теми же инструментами, что и при нормальной разработке. Приходится изучать, как все устроено, выводя информацию либо в логи, либо через UI в самой игре.
Шаг 2: взаимодействуем с логикой самой игры
На этом шаге начинаем контактировать с игрой. Будем считать активное время, проведенное в геймплее, и прятать UI мода, когда он не нужен. Для этого нужно научиться определять переходы из меню в основной геймплей и определять, поставили ли игру на паузу.
Обновляем метод Update. Теперь будем использовать логическую переменную _trackActiveTime, чтобы включать и выключать отслеживание активного времени. Ну и выводим его в новое текстовое поле _activeTimeText. Создаем его так же, как и остальные, просто сдвигаем координаты чуть пониже.
private void Update() < if (_trackActiveTime) < _activeTime += Time.deltaTime; >if (Time.time >= _nextTextUpdate) < _currentTimeText.text = DateTime.Now.ToString("HH:mm"); _totalTimeText.text = $"Total: :"; _activeTimeText.text = $"Active: :"; _nextTextUpdate += TEXT_UPDATE_PERIOD; > >
Теперь добавляем метод для включения и выключения отслеживания активного времени:
private void SetTrackingMode(bool isTracking)
Здесь мы устанавливаем _trackActiveTime и скрываем текстовые поля. Это заодно решает проблему из прошлого этапа, когда время показывалось в основном геймплее.
Теперь нам нужно каким-то образом сделать так, чтобы основная игра вызывала SetTrackingMode(true), когда мы запускаем какой-то уровень, и SetTrackingMode(false), когда мы возвращаемся в меню или ставим игру на паузу. Проще всего это сделать через события. Для начала пойдем простым путем и добавим мод, который упрощает взаимодействие с игрой, а потом уже посмотрим, как это делается руками.
Нам нужен мод BS_Utils. Добавляем в список зависимостей проекта библиотеку BS_Utils.dll из папки Beat Saber/Plugins (мы ее установили когда ставили моды через ModAssistant). Теперь добавляем BS_Utils в манифест. Это нужно для того, чтобы наш мод загружался после него.
"dependsOn": < "BS Utils": "^1.4.0" >,
Находим в событиях BS_Utils те, которые нам нужны, подписываемся на них и переключаем отслеживание активного времени.
BSEvents.gameSceneActive += EnableTrackingMode; BSEvents.menuSceneActive += DisableTrackingMode; BSEvents.songPaused += DisableTrackingMode; BSEvents.songUnpaused += EnableTrackingMode;
Методы EnableTrackingMode и DisableTrackingMode я добавил просто для удобства, чтобы можно было их использовать как делегаты в событиях без аргументов.
private void EnableTrackingMode() < SetTrackingMode(true); >private void DisableTrackingMode()
Собираем проект, копируем dll в Plugins, запускаем игру, проверяем.
Если бы мы просто разрабатывали мод для Beat Saber, то на этом этапе можно было бы и остановиться. Мод готов, он делает то, что мы хотели, и так, как мы хотели. Он использует сторонний мод BS_Utils, но почти все моды используют его. BS_Utils поддерживается одним из главных разработчиков в сообществе BSMG, так что не нужно переживать, что в какой-то момент он перестанет работать. Но это познавательная статья, поэтому мы пойдем дальше. И мы еще не все разобрали, что нужно для разработки модов.
Вывод из шага 2
Если у игры большое сообщество моддеров, то, скорее всего, они уже сделали многое, чтобы облегчить работу друг другу. Например, в Beat Saber мод BS_Utils значительно упрощает работу с кодом игры, а BSML — это мод, позволяющий создавать графический интерфейс с помощью xml-конфигураций.
Шаг 3: удаляем BS_Utils, лезем в код игры
Удаляем BS_Utils из зависимостей проекта и из манифеста. Компилятор сообщает нам, что BSEvents и его события теперь не определены. Их мы и будем заменять на этом шаге.
menuSceneActive и gameSceneActive
Эти события срабатывают, когда активируется сцена с меню и сцена с основным геймплеем соответственно. Для работы со сценами у Unity есть статический класс SceneManager, у которого есть события sceneLoaded, sceneUnloaded и activeSceneChanged. Добавляем обработчики событий для них и просто выводим названия сцен в логи. Так как мы уже добавили библиотеку UnityEngine.CoreModule.dll в зависимости, проблем с определением SceneManager быть не должно.
private void Awake() < . SceneManager.sceneLoaded += OnSceneLoaded; SceneManager.sceneUnloaded += OnSceneUnloaded; SceneManager.activeSceneChanged += OnActiveSceneChanged; . >private void OnSceneLoaded(Scene scene, LoadSceneMode mode) < Plugin.logger.Debug("OnSceneLoaded: " + scene.name + " (" + mode + ")"); >private void OnSceneUnloaded(Scene scene) < Plugin.logger.Debug("OnSceneUnloaded: " + scene.name); >private void OnActiveSceneChanged(Scene previous, Scene current) < Plugin.logger.Debug("OnActiveSceneChanged: " + previous.name + " ->" + current.name); >
Собираем мод, запускаем игру, заходим в основной геймплей, выходим из него, выходим из игры, смотрим логи.
[DEBUG @ 14:28:14 | BeatSaberTimeTracker] Plugin.Init [DEBUG @ 14:28:14 | BeatSaberTimeTracker] Plugin.OnStart [DEBUG @ 14:28:14 | BeatSaberTimeTracker] TimeTracker.Awake() [DEBUG @ 14:28:15 | BeatSaberTimeTracker] OnSceneLoaded: EmptyTransition (Additive) [DEBUG @ 14:28:15 | BeatSaberTimeTracker] OnActiveSceneChanged: PCInit -> EmptyTransition [DEBUG @ 14:28:15 | BeatSaberTimeTracker] OnSceneLoaded: MainMenu (Additive) [DEBUG @ 14:28:15 | BeatSaberTimeTracker] OnSceneLoaded: MenuCore (Additive) [DEBUG @ 14:28:15 | BeatSaberTimeTracker] OnSceneLoaded: MenuEnvironment (Additive) [DEBUG @ 14:28:15 | BeatSaberTimeTracker] OnSceneLoaded: MenuViewControllers (Additive) [DEBUG @ 14:28:15 | BeatSaberTimeTracker] OnActiveSceneChanged: EmptyTransition -> MenuViewControllers [DEBUG @ 14:28:15 | BeatSaberTimeTracker] OnSceneUnloaded: EmptyTransition [DEBUG @ 14:28:22 | BeatSaberTimeTracker] OnSceneLoaded: BigMirrorEnvironment (Additive) [DEBUG @ 14:28:22 | BeatSaberTimeTracker] OnSceneLoaded: StandardGameplay (Additive) [DEBUG @ 14:28:23 | BeatSaberTimeTracker] OnSceneLoaded: GameplayCore (Additive) [DEBUG @ 14:28:23 | BeatSaberTimeTracker] OnSceneLoaded: GameCore (Additive) [DEBUG @ 14:28:23 | BeatSaberTimeTracker] OnActiveSceneChanged: MenuViewControllers -> GameCore [DEBUG @ 14:28:29 | BeatSaberTimeTracker] OnActiveSceneChanged: GameCore -> MenuViewControllers [DEBUG @ 14:28:29 | BeatSaberTimeTracker] OnActiveSceneChanged: MenuViewControllers -> MainMenu [DEBUG @ 14:28:29 | BeatSaberTimeTracker] OnActiveSceneChanged: MainMenu -> MenuCore [DEBUG @ 14:28:29 | BeatSaberTimeTracker] OnActiveSceneChanged: MenuCore -> MenuEnvironment [DEBUG @ 14:28:29 | BeatSaberTimeTracker] OnActiveSceneChanged: MenuEnvironment -> MenuViewControllers [DEBUG @ 14:28:29 | BeatSaberTimeTracker] OnSceneUnloaded: BigMirrorEnvironment [DEBUG @ 14:28:29 | BeatSaberTimeTracker] OnSceneUnloaded: StandardGameplay [DEBUG @ 14:28:29 | BeatSaberTimeTracker] OnSceneUnloaded: GameplayCore [DEBUG @ 14:28:29 | BeatSaberTimeTracker] OnSceneUnloaded: GameCore [DEBUG @ 14:28:34 | BeatSaberTimeTracker] Plugin.OnExit [DEBUG @ 14:28:34 | BeatSaberTimeTracker] TimeTracker.OnDestroy()
Здесь так много разных сцен, потому что Beat Saber использует разные сцены для разных компонентов и загружает их в режиме Additive. Интерфейс на одной сцене, платформа с игроком — на другой. Анализируем логи и делаем вывод: отслеживать переход в основной геймплей можно, например, при активации сцены GameCore. По аналогии, переход в меню — по активации сцены MenuCore. Но с MenuCore есть проблема — судя по логам, она не активируется при запуске игры, когда мы только попадаем в меню. Поэтому для меню лучше использовать сцену MenuViewControllers. Еще одно полезное наблюдение: сцены для меню загружаются один раз при запуске игры и просто деактивируются при запуске геймплея, а вот сцены геймплея загружаются заново при запуске уровня. Это нам еще пригодится.
Обновляем OnActiveSceneChanged: проверяем имя сцены и переключаем отслеживание активного времени:
private void OnActiveSceneChanged(Scene previous, Scene current) < Plugin.logger.Debug("OnActiveSceneChanged: " + previous.name + " ->" + current.name); switch (current.name) < case "MenuViewControllers": DisableTrackingMode(); break; case "GameCore": EnableTrackingMode(); break; >>
songPaused и songUnpaused
Для следующих событий придется покопаться в коде игры, поэтому переходим к настоящему реверс-инжинирингу. Теперь нам нужна библиотека, в которой содержится код Beat Saber. В папке «Beat Saber/Beat Saber_Data/Managed» лежат 2 библиотеки: Main.dll и MainAssembly.dll. Я сначала копался в MainAssembly.dll, из-за чего потратил 2 дня на отладку одного очень странного поведения. Оказалось, что по какой-то причине и Main.dll, и MainAssembly.dll содержат определения одних и тех же классов. Я использовал MainAssembly.dll, а в игре использовались классы из Main.dll. Возможно, какая-то ошибка при сборке билда у разработчиков игры.
Судя по тому, что я узнал и посмотрел в других модах, все, что нам нужно, лежит в библиотеке Main.dll. Нам нужно посмотреть ее содержимое, а для этого нужен декомпилятор. На сайте BSMG советуют использовать dnSpy. Я использую Rider в качестве среды разработки, и у него есть встроенный декомпилятор, поэтому про dnSpy ничего конкретного сказать не могу, не пользовался. Но, судя по описанию, вещь полезная — это не только декомпилятор, но еще и дебаггер, который может подключаться к Unity-процессам.
Дальше идет рутина: берем содержимое Main.dll и ищем класс, который делает то, что нам нужно. Это сложно, но по-другому никак. Разве что можно пойти в Discord-канал BSMG и спросить. Вам, скорее всего, ответят, потому что там много людей, которые уже когда-то декомпилировали Main.dll и что-то там искали (и нашли).
Рано или поздно мы найдем класс GamePause, который отвечает в игре за включение и выключение паузы. У него есть два метода: Pause и Resume. А еще у GamePause есть два события: didPauseEvent и didResumeEvent. Отлично, нам даже не пришлось делать что-то сложное, у GamePause уже есть события, на которые мы можем подписаться.
Значит, нам каким-то образом нужно получить ссылку на компонент GamePause. В Unity это можно сделать так:
Resources.FindObjectsOfTypeAll();
Этому методу все равно, на какой сцене компонент, что за объект и активен ли он. Если компонент создан, он будет найден. Но нужно как-то найти момент времени, когда этот компонент создан. Можно предположить, что он висит на каком-то объекте на одной из сцен в геймплее. Мы уже выяснили, что геймплейные сцены каждый раз создаются заново. У нас есть обработчики событий OnSceneLoaded и OnActiveSceneChanged, поэтому мы можем отловить там сцену GameCore и в этот момент попробовать получить ссылку на GamePause. Проблема в том, что он может создаваться динамически чуть позже, чем загружаются сцены, поэтому тут есть два варианта: поискать в игре событие, которое срабатывает после того, как GamePause создан (вряд ли такое есть), либо вызывать Resources.FindObjectsOfTypeAll каждый кадр, пока не найдем компонент. Например, через корутину:
IEnumerator InitGamePauseCallbacks() < while (true) < GamePause[] comps = Resources.FindObjectsOfTypeAll(); if (comps.Length > 0) < Plugin.logger.Debug("GamePause has been found"); GamePause gamePause = comps[0]; gamePause.didPauseEvent += DisableTrackingMode; gamePause.didResumeEvent += EnableTrackingMode; break; >Plugin.logger.Debug("GamePause not found, skip a frame"); yield return null; > >
Запускаем ее в OnActiveSceneChanged для сцены GameCore:
private void OnActiveSceneChanged(Scene previous, Scene current) < Plugin.logger.Debug("OnActiveSceneChanged: " + previous.name + " ->" + current.name); switch (current.name) < case "MenuViewControllers": DisableTrackingMode(); break; case "GameCore": EnableTrackingMode(); StartCoroutine(InitGamePauseCallbacks()); break; >>
Собираем мод, запускаем игру и убеждаемся, что все работает. Также можно заглянуть в логи. Там видно, что GamePause существует сразу же после активации GameCore, а значит, корутина не нужна и можно ее убрать. Я решил оставить для надежности.
Вывод из шага 3
Чтобы сделать мод для игры, нужно знать ее архитектуру и исходный код. Для этого приходится много времени тратить с декомпилятором, копаясь в исходном коде и пытаясь понять, как там все устроено. А копаться в чужом коде не всегда легко и приятно.
Шаг 4: вмешиваемся в логику игры с помощью Harmony
На этом этапе начинается магия, мы взглянем на Harmony — библиотеку для модификации C#-кода, которая используется моддерами во многих играх. Ее автор — Andreas Pardeike (сайт, GitHub), работает ведущим iOS-разработчиком / архитектором в шведской полиции (Swedish Police Authority). В отличие от библиотеки Mono.Cecil из прошлой статьи про моды, которая модифицирует и перезаписывает dll-файлы с .NET-сборками, Harmony модифицирует код во время исполнения программы (runtime). Модифицировать можно только методы, что обычно достаточно, так как нам нужно модифицировать именно поведение, а не состояние. Для модификации состояния есть много других способов, в том числе стандартных.
Модификации Harmony в терминах самой библиотеки называются патчами (patches). Есть несколько видов патчей:
- Prefix. Патч, который вызывается перед выполнением метода. С его помощью можно перехватить и изменить аргументы метода, либо решить, нужно ли вызывать сам метод или сразу выйти из него.
- Postfix. Патч, который вызывается после выполнения метода. Можно перехватить и изменить возвращаемое значение.
- Transpiler. Патч, который на ходу модифицирует скомпилированный IL-код. Можно использовать, если нужно изменить логику где-то в середине метода.
- Finalizer. С этим патчем мы как бы оборачиваем оригинальный метод в конструкцию try/catch/finally, а сам патч является обработчиком одновременно и catch, и finally.
Самые популярные патчи — это Prefix и Postfix. Transpiler слишком сложный, так как это уже не C#, а IL-код, да и зачастую проще скопировать исходный метод через декомпилятор, изменить там что-то и заменить весь метод через Prefix/Postfix. Finalizer звучит полезно, но он появился только недавно, в Harmony 2.0, поэтому примеров его использования я еще не видел.
Когда я только придумывал идею для мода, я думал, что Harmony мне понадобится сразу же, как только я решу убрать BS_Utils. Оказалось, что GamePause сам по себе содержит все нужные события, и теперь придется искусственно усложнить задачу, чтобы показать, как работает Harmony. Давайте представим, что в GamePause нет событий didPauseEvent и didResumeEvent, и нам нужно что-то с этим сделать.
Так как мы все еще придерживаемся принципа единственной ответственности, создаем класс HarmonyPatcher. У него будет всего один метод: public static void ApplyPatches() <>, в котором будет примерно такой код:
Harmony harmony = new Harmony("com.fck_r_sns.BeatSaberTimeTracker"); harmony.PatchAll(Assembly.GetExecutingAssembly());
Этих двух строк достаточно, чтобы установить все патчи, который у нас есть (но их пока нет). «com.fck_r_sns.BeatSaberTimeTracker» — это имя пакета. Оно должно быть уникальным, чтобы не было коллизий с патчами из других модов. Теперь идем в класс Plugin, который у нас отвечает за старт и инициализацию мода, и добавляем туда вызов HarmonyPatcher.ApplyPatches() перед созданием TimeTracker.
Переходим к написанию самих патчей. Для каждого метода, который мы хотим модифицировать, нужно написать отдельный класс. Каждый патч — это статический метод в этом классе. Чтобы указать, что это за патч, мы можем либо использовать соответствующее имя метода (например, метод с именем Prefix — это Prefix-патч), либо использовать любые имена и помечать методы атрибутами (например, [HarmonyPrefix]). Я всегда предпочитаю, чтобы код был явным и легко читаемым, поэтому я сторонник подхода с атрибутами. Начнем с патчей для метода GamePause.Pause(). Добавим в него Postfix-патч, который просто пишет в лог, что был вызван метод Pause() и сработал Postfix-патч.
[HarmonyPatch(typeof(GamePause), nameof(GamePause.Pause), MethodType.Normal)] class GamePausePausePatch < [HarmonyPostfix] static void TestPostfixPatch() < Plugin.logger.Debug("GamePause.Pause.TestPostfixPatch"); >>
Атрибут [HarmonyPatch] указывает, какие класс и метод нам нужно модифицировать. Статический метод TestPostfixPatch помечен атрибутом [HarmonyPostfix], поэтому это Postfix-патч. Создаем аналогичный класс для GamePause.Resume() (можно в том же файле), собираем, запускаем игру, запускаем уровень, жмем паузу, снимаем паузу, выходим из игры, проверяем логи.
Проверяем, что патчи применились:
[DEBUG @ 16:21:55 | BeatSaberTimeTracker] Plugin.Init [DEBUG @ 16:21:55 | BeatSaberTimeTracker] Plugin.OnStart [DEBUG @ 16:21:55 | BeatSaberTimeTracker] HarmonyPatcher: Applied [DEBUG @ 16:21:55 | BeatSaberTimeTracker] TimeTracker.Awake()
Проверяем, что Postfix-патчи сработали:
[DEBUG @ 16:22:24 | BeatSaberTimeTracker] GamePause.Pause.TestPostfixPatch [DEBUG @ 16:22:31 | BeatSaberTimeTracker] GamePause.Resume.TestPostfixPatch
Отлично, Harmony работает, можно переходить к логике. В нашем искусственном примере мы представили, что событий didPauseEvent и didResumeEvent не существует, а значит, нам нужно в Postfix-патчах что-то сделать, чтобы TimeTracker включал и выключал отслеживание активного времени. Тут мы натыкаемся на главную проблему Harmony — все патчи являются статическими методами. А TimeTracker — это компонент, который висит где-то в иерархии объектов и статическим явно не является. Тут я вижу два нормальных решения этой задачи.
Первый — это сделать TimeTracker доступным из статического контекста. Например, сделать его синглтоном или каждый раз получать на него ссылку через Resources.FindObjectsOfTypeAll(). В BS_Utils, например, используется синглтон.
Второй — это добавить класс со статическими событиями вроде BS_Utils.Utilities.BSEvents, который мы использовали на ранних этапах. Этот вариант мне нравится больше, давайте реализовывать его.
Создаем класс EventsHelper:
namespace BeatSaberTimeTracker < public static class EventsHelper < public static event Action onGamePaused; public static event Action onGameResumed; >>
Теперь обновляем наши патчи, чтобы они вызывали эти события:
[HarmonyPatch(typeof(GamePause), nameof(GamePause.Pause), MethodType.Normal)] class GamePausePatchPause < [HarmonyPostfix] static void FireOnGamePausedEvent() < EventsHelper.FireOnGamePausedEvent(); >>
GamePauseResumePatch делается аналогично. Пришлось добавить публичные методы FireOnGamePausedEvent и FireOnGameResumedEvent, так как нельзя вызывать события из-за пределов их класса. Теперь TimeTracker может в любой момент подписаться на события в EventsHelper. Получаем код со слабым зацеплением — именно из-за этого подход с событиями мне нравится больше, чем вариант с синглтоном или Resources.FindObjectsOfTypeAll().
Если мы соберем мод и запустим игру, то все будет работать. Однако, мы пока не учли одну деталь. В оригинальном коде GamePause.Pause() есть проверка от многократного перехода в режим паузы.
if (this._pause) return; this._pause = true; …
Postfix-патч же будет вызван в любом случае: и если мы установили паузу, и если это было повторное нажатие. А значит, и событие EventsHelper будет срабатывать всегда, даже если фактического перехода в паузу уже не было. Давайте добавим Prefix-патч, в котором будем проверять текущее состояние паузы. Harmony позволяет читать и изменять приватные переменные класса, а также передавать состояние между патчами одного метода. В Harmony вообще много чего можно получить в патче:
- Аргументы метода: собственно то, что было передано в метод при его вызове.
- __instance: ссылка на текущий объект, для которого вызван метод. По сути это просто this.
- __state: переменная любого типа для передачи состояния между патчами. Если нужно несколько переменных, то просто пишем структуру или класс.
- __result: возвращаемый результат оригинального метода. Если нужно, можно его изменить.
- Приватные переменные: добавляем три (3) знака подчеркивания (_) перед названием аргумента в патче, и Harmony подставит туда значение из приватной переменной.
Начнем со структуры, которая будет хранить состояние:
struct PauseState
Нам нужно всего одно значение, чтобы отслеживать состояние паузы, поэтому структура избыточна, но как я уже писал выше, я люблю ясный код. PauseState __state — это более ясный код, чем просто bool __state .
Теперь добавляем Prefix-патч:
[HarmonyPrefix] static void CheckIfAlreadyPaused(out PauseState __state, bool ____pause) < __state = new PauseState < wasPaused = ____pause >; >
Здесь мы добавляем состояние с модификатором out, чтобы его можно было изменять, и приватную переменную ____pause ( _pause и еще три подчеркивания перед ней). Просто сохраняем ____pause в __state — тут ничего хитрого.
Теперь обновляем Postfx-патч:
[HarmonyPostfix] static void FireOnGamePausedEvent(PauseState __state, bool ____pause) < if (!__state.wasPaused && ____pause) < EventsHelper.FireOnGamePausedEvent(); >>
__state даст нам ту же структуру, которую мы записали в Prefix-патче. Сравниваем wasPaused с ____pause , чтобы проверить, что игра реально поставлена на паузу и вызываем событие.
Запускаем игру и проверяем, что все работает.
Вывод из шага 4
Harmony — это очень полезная и важная для сообщества моддеров библиотека, которая используется в RimWorld, Battletech, Cities: Skylines, Kerbal Space Program, Oxygen Not Included, Stardew Valley, Subnautica и многих других играх.
Заключение
Создание модов — это довольно утомительный процесс. При разработке модов нужно постоянно копаться в декомпилированном коде, искать классы, которые делают то, что вам нужно, модифицировать их, постоянно пересобирать моды, чтобы проверить изменения в игре, страдать из-за отсутствия нормального режима отладки и полноценного Unity-редактора.
А потом разработчики выпускают новую версию игры, в которой они поменяли логику, которая использовалась в моде, и нужно делать все сначала.
- unity
- реверс-инжиниринг
- разработка игр