В этой части на примере мода для Beat Saber мы рассмотрим общие принципы разработки модов для Unity-игр, узнаем, какие есть трудности, а также познакомимся с Harmony — библиотекой для модификации кода игр, которая используется в RimWorld, Battletech, Cities: Skylines и многих других играх.
Хоть эта статья и похожа на туториал, как написать свой мод для Beat Saber, ее цель — показать, какие принципы используются при создании любых пользовательских модов и какие проблемы приходится решать при разработке. Все, что здесь описано, с некоторыми оговорками применимо для всех Unity-игр как минимум в Windows.
Источники изображений: 1, 2
В предыдущей серии
Прошлая часть
Информация из первой части не нужна для понимания того, что будет происходить здесь, но все равно советую с ней ознакомиться.
Вот ее краткое (очень) содержание:
Программные моды (также известные как плагины) — это 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": "https://github.com/beat-saber-modding-group/BSIPA-MetadataFileSchema/blob/master/Schema.json",
"author": "fck_r_sns",
"description": "A mod to track active time spent in the game",
"gameVersion": "1.8.0",
"id": "BeatSaberTimeTracker",
"name": "BeatSaberTimeTracker",
"version": "0.0.1-alpha",
"dependsOn": {}
}
$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>();
_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>();
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: {Mathf.FloorToInt(Time.time / 60f):00}:{Mathf.FloorToInt(Time.time % 60f):00}";
_nextTextUpdate += TEXT_UPDATE_PERIOD;
}
}
Теперь обновляем наш класс Plugin, чтобы он создавал объект TimeTracker:
[OnStart]
public void OnStart()
{
logger.Debug("OnStart");
GameObject timeTrackerGo = new GameObject("TimeTracker");
timeTrackerGo.AddComponent<TimeTracker>();
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: {Mathf.FloorToInt(Time.time / 60f):00}:{Mathf.FloorToInt(Time.time % 60f):00}";
_activeTimeText.text = $"Active: {Mathf.FloorToInt(_activeTime / 60f):00}:{Mathf.FloorToInt(_activeTime % 60f):00}";
_nextTextUpdate += TEXT_UPDATE_PERIOD;
}
}
Теперь добавляем метод для включения и выключения отслеживания активного времени:
private void SetTrackingMode(bool isTracking)
{
_trackActiveTime = isTracking;
_canvas.gameObject.SetActive(!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()
{
SetTrackingMode(false);
}
Собираем проект, копируем 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<GamePause>();
Этому методу все равно, на какой сцене компонент, что за объект и активен ли он. Если компонент создан, он будет найден. Но нужно как-то найти момент времени, когда этот компонент создан. Можно предположить, что он висит на каком-то объекте на одной из сцен в геймплее. Мы уже выяснили, что геймплейные сцены каждый раз создаются заново. У нас есть обработчики событий OnSceneLoaded и OnActiveSceneChanged, поэтому мы можем отловить там сцену GameCore и в этот момент попробовать получить ссылку на GamePause. Проблема в том, что он может создаваться динамически чуть позже, чем загружаются сцены, поэтому тут есть два варианта: поискать в игре событие, которое срабатывает после того, как GamePause создан (вряд ли такое есть), либо вызывать Resources.FindObjectsOfTypeAll каждый кадр, пока не найдем компонент. Например, через корутину:
IEnumerator InitGamePauseCallbacks()
{
while (true)
{
GamePause[] comps = Resources.FindObjectsOfTypeAll<GamePause>();
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
{
public bool wasPaused;
}
Нам нужно всего одно значение, чтобы отслеживать состояние паузы, поэтому структура избыточна, но как я уже писал выше, я люблю ясный код. 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-редактора.
А потом разработчики выпускают новую версию игры, в которой они поменяли логику, которая использовалась в моде, и нужно делать все сначала.