Adding mod support in Unity
Many, many great things have evolved from game modding. Popular games such as Counter-Strike and Rocket League were born as simple, humble modifications of someone else’s game, and look where they are now. Modding is an important part of any game’s ecosystem, which is why it’s vital for developers to support it. But how the hell do you allow for modders to do what they do best with your game in Unity easily? Enter Mod.io.
What is it?
Mod.io is a modding platform and API. When you integrate it into your game, the user can download a mod from the mod.io page, or in-game. Mod.io’s Unity Asset Store package comes with a built-in example scene of a mod browser, similar to the one in Garry’s Mod. Essentially, it’s a glorified download manager.
A “mod” in our case consists of a zip file, containing an Asset Bundle. When we subscribe to the mod in the mod manager, mod.io will automatically unzip its contents and place them into a folder, specified in the plugin’s settings.
Let’s start by opening the scene provided in Assets/Plugins/mod.io/Mod Browser/Example Scene.
Asset Bundles
Once you have downloaded the asset store package and set it up correctly, it’s time to actually add mod support.
First of all, Unity has something called Asset Bundles. An AssetBundle is sort of like a zip file: a file that contains files itself. Asset-ception. This is the file that we want to read from our mod.
Creating an Asset Bundle
To create an Asset Bundle, we will create a new C# Script inside the folder Assets/Editor called CreateAssetBundles.cs . This code was taken directly from the Unity Docs page for Asset Bundles Workflow:
This script will add a context menu item called Build AssetBundles.
To assign an asset to a Bundle, find the AssetBundle option at the bottom of the Inspector, and where it says “none”, click the drop-down and choose “New”. You will be prompted to give it a name. I’m going to call mine modbundle .
Once that’s done, build the Asset Bundles, and you should see a new folder (unless you already had one), and inside it, your Asset Bundle.
Converting an Asset Bundle to a Mod
Opening your project in explorer, navigate to where your bundle is (e.g. Assets/AssetBundles/modbundle ) and click Send to -> Compressed (Zipped) file, or add it to a zip file however you see fit. We will be uploading this file to our Mod.io page. Find out how to do so on their website.
Reading an Asset Bundle from a Mod
First of all, we need to create a new C# Script and add these lines at the top of the file:
Now we specify the name of the bundle we want to load. This is what modders will name their bundles when exporting from Unity. My Asset Bundle is called modbundle , so I’m going to set this to modbundle .
Now, we want to create a new public function called LoadMods() . This is so we can call this at any time. I’m also going to call mine from the Start() method, for testing purposes.
Each mod is stored as a string, which represents the path to the mod’s contents. This is why our list of mods is a string list. We will initialize our list in the LoadMods() method we just made:
Now we will need to loop over each one of the mods to get their content individually.
This code will tell us what mods we have loaded.
We’re already halfway there! All we have left to do is loading the Asset Bundle and doing whatever you want to do with said Asset Bundle. To load the bundle, we will get the path by combining our mod’s path with our bundle name, essentially pointing it to «modpath/assetbundle» .
Here, we’re using Path.Combine as it’s cleaner than making the string manually, and works cross-platform (Linux and Mac like to use different slashes in paths to Windows).
Now we attempt to load the file, and throw an error if the file can’t be read.
And believe it or not, all of the main code is done!
Now you have loaded your mods, you will want to access their contents. In my case, I want to load a Prefab in the Asset Bundle called MyObject and spawn it in the scene.
While this is unrealistic and very simple, it should give you a taste of the Mod.io API’s possibilities. You can use loaded.LoadAllAssets() to produce an array of Game Objects GameObject[] and use loops to go over each one and do something with it. Here’s an example of that:
Using Tags is a good way to differentiate assets. For example, we could have all objects tagged “weapon” be placed in front of the player and parented to the camera, so they act like weapons.
Games using Mod.io
These games are currently using Mod.io:
Full script (InitMods.cs)
This is the final script. It will load one prefab, called “MyObject”, and instantiate it in the scene. You can find this script on my GitHub Gist page.
How to create mod for unity game
Note, this tutorial is more focused on creating scripts than content, but you can still use it to make a new game object or texture. Unity runs the C# programming language, so creating a mod isn’t as hardcore as it seems. You don’t need to be a super programmer, just the basics of programming. The hardest part will be digging into the game’s code to find the necessary functions you want to call/modify.
Project creation
- Download and install Microsoft Visual Studio Community 2017 with C#
- Open the project creation and select ‘Class Library (NET Framework)’. Change name to ‘TestMod’. Open the project properties and target platform should be ‘Net Framework 3.5’ or ‘Net Framework 4.0’ for the newer Unity version. If game is made for .net 3.5, UMM must be installed as Doorstop method.
- You need to add references to game files ‘Assembly-CSharp.dll’, ‘Assembly-CSharp-firstpass.dll’, ‘UnityEngine.dll’, ‘UnityEngine.UI.dll’ which are located in ‘Managed’ folder and to UMM files ‘UnityModManager.dll’, ‘0Harmony.dll’ which are located in ‘Managed/UnityModManager’ folder. If the unity version is 2017 or higher you need to add additional files ‘UnityEngine.CoreModule.dll’, ‘UnityEngine.IMGUIModule.dll’. Now we can use the game and unity mod manager functions. Note: Unity Mod Manager should already be installed.
Information file
Create an information file ‘Info.json’ so that the mod manager can read it and determine which files need to load when starting the game. The file is written in json and must be placed in the ‘Mods’ folder and one more folder e.g. ‘\Steam\steamapps\common\YourGame\Mods\TestMod\Info.json’.
OR short variant
Id — Unique string. It is desirable to match the folder name. (Required)
DisplayName — Name or Title. (Optional)
Author — (Optional)
Version — Needs for dependent mods and to checking for updates (Format must be ‘x.x.x’). (Required)
ManagerVersion — Minimum required version of the mod manager (Format must be ‘x.x.x’). (Recommended)
GameVersion — Minimum required version of game. Works if game supports this option. (Format must be ‘x.x.x’) (Optional)
Requirements — Minimum required version of mods or just other required mod. (Optional)
LoadAfter — List of mods that will be loaded first. (Optional) [0.22.5]
AssemblyName — Filename we are creating. Default like ‘Id’ (e.g. TestMod.dll). (Optional)
EntryMethod — A function that will be called by the mod manager at load game. (Required)
HomePage — Web address. (Optional)
Repository — Web address to check for updates. (Optional)
Assembly file
The mod manager supports several variants of the Entry functions. Use only one. Let’s call it Load. The name must be the same as in the EntryMethod.
You can add a function that will control on/off mode similar to hot-plug. This function is optional. If the function is missing, the mod may turn on, but it will turn off only after restarting the game.
Details of ModEntry
Info — Contains all fields from the ‘Info.json’ file.
Path — The path to the mod folder e.g. ‘\Steam\steamapps\common\YourGame\Mods\TestMod\’.
Active — Active or inactive.
Logger — Writes logs to the ‘Log.txt’ file.
OnToggle — The presence of this function will let the mod manager know that the mod can be safely disabled during the game.
OnGUI — Called to draw UI.
OnSaveGUI — Called while saving.
OnUpdate — Called by MonoBehaviour.Update.
OnLateUpdate — Called by MonoBehaviour.LateUpdate.
OnFixedUpdate — Called by MonoBehaviour.FixedUpdate.
OnShowGUI — Called when opening mod GUI.
OnHideGUI — Called when closing mod GUI.
Examples
A simple example of how to bind any action to keys.
Note: With the new Unity Engine update, some games may require you to reference UnityEngine.InputLegacyModule before you can use the UnityEngine.Input function as usual.
An example of how to draw a mod menu for a UMM UI. Some IMGUI documentation.
Harmony
With harmony patches, you can completely take control of game functions. How to do this better to read the official Wiki. Here is an example.
Now the function ‘Application.loadedLevelName’ will always return «New Level Name» string. Similarly, we can change any value in the game.
Be sure to wrap the patch with a Try-Catch block so as not to interrupt an original function in case of errors. Errors can occur if a game code changes after updates.
Harmony 2 requires a minimum ManagerVersion 0.22.0.
Loading custom textures or predefined assets.
In the Unity editor, use AssetBundles to create an asset file. Copy it to the mod folder, now you can load it using the code.
Can also be loaded jpg and png directly, but in this case they will be uncompressed and take much more video memory.
These functions may require additional libraries UnityEngine.AssetBundleModule.dll and UnityEngine.ImageConversionModule.dll.
The editor version should not be higher than the Unity version in a game.
Finally
Last, compile this code and copy the ‘TestMod.dll’ file to the ‘Info.json’ file. After starting the game, you will see messages in the ‘Log.txt’ file. Also detailed log can be found in ‘YourGame\YourGame_Data\output_log.txt’ or ‘c:\Users%USERNAME%\AppData\LocalLow\YourGame\output_log.txt’.
Additionally
You can explore the game code using the dnspy tool. Also available source code of my mods.
Unity как создать аддон для своей игры
Добавление скриптинга и динамических аддонов в Unity (часть 1)
Требуется разделить игру на движок и игровой контент (сюжет, геймплей, диалоги, задания для игрока, и так далее).
Контент будет содержать сложную логику, поэтому контент нужно представить не просто в виде текстовых файлов или БД. Для описания контента нужно использовать скриптовый язык программирования.
При этом нужно разделить разработку движка и сюжета на несколько независимых проектов. А еще лучше сделать систему аддонов и плагинов, с помощью которой дополнительные сюжетные линии могут разрабатывать сторонние разработчики и подгружаться в игру прямо в рантайме.
Опишу здесь как написать такую систему для Unity. В результате будет система скриптинга и аддонов, с возможностью отдельной компиляции скриптов и динамической подгрузкой их в игру, в рантайме.
Сразу определимся с терминологией: здесь и далее под скриптами я буду подразумевать не те скрипты, которые MonoBehaviour из Unity, а именно скриптинг — то есть скрипты которые описывают контент игры.
В целом скриптинг работает так. В основном движке разрабатывается абстрактный класс (назовем его ScriptingBase) который содержит набор методов для взаимодействия скриптов с основным движком игры. ScriptingBase вынесем в отдельный namespace.
Например это может выглядеть так:
Сюжетные скрипты наследуются от ScriptingBase, в них описывается игровая логика и сюжет, а взаимодействие с движком происходит через вызов методов базового класса ScriptingBase.
Скрипт просто вызывает метод для показа сообщения игрока и запоминает, что сообщение уже показано, что бы не показывать его снова.
Далее в движке пишем простой контроллер, который может показывать текст игроку. И еще пишем MonoBehaviour класс PluginsController который будет заниматься загрузкой и выполнением скриптов.
Unity как создать аддон для своей игры
1
1
1

| 436 | уникальных посетителей |
| 19 | добавили в избранное |












Для начала работы нам необходимо скачать и установить Unity hub. Для этого нам необходимо перейти на официальный сайт [unity3d.com] Unity и скачать Unity hub.
Теперь нам необходимо установить Unity, на наш компьютер. Для этого запускаем наш только что скаченный файл, после нажимаем на кнопку принять в нижнем правом углу. У нас появляется путь установки, который при желании можно изменить. После нажимаем на кнопку установить, и ждём некоторое время. После успешной установки закрываем программу установки.
Теперь для того чтобы нам скачать программу, необходимо зайти в Unity Hub, перейти в раздел Installs, нажать на кнопку Install Editor, и выбрать версию 2019.4.34f1. Дожидаемся полной установки, это довольно долгий процесс
После загрузки Unity, нам необходимо создать проект в Unity. Для этого заходим в Unity hub, нажимаем на кнопку New project в верхнем правом углу, выбираем проект 3d core, меняем название проекта, и нажимаем на кнопку Create project.
После того как мы создали проект в Unity, заходим в него, важно что бы Unity был открыт при импорте файлов. Нам нужно импортировать все необходимые ресурсы, для создания и экспорта нашего мода. Заходим в steam библиотеку, нажимаем правой кнопкой мыши по иконе игры, далее свойства, локальные файлы. У нас откроется папка с игрой Unturned, открываем Bundles, Sources. У нас будут 2 файла ExampleAssets и Project, необходимо загрузить оба файла в наш проект Unity. Для этого жмём по одному из них 2 раза левой кнопкой мыши, после у нас выскакивает окно, в котором необходимо выбрать программу, выбираем Unity. После у нас откроется Unity, и начнёт загрузку. После загрузки, у нас появится окно, в правом нижнем углу которого, необходимо нажать на кнопку Import. Unity начнёт загрузку файлов. После успешной загрузки, повторяем то же самое с другим файлом.
Перед началом создания, нам необходимо создать иерархию, для будущего экспорта в MasterBundle и редактирования Vehicle. Для начала, нам необходим создать папку с любым названием. Для этого нажимаем правой кнопкой мышки, по нижней серой области, выскакивает большое окно, вверху которого нажимаем на кнопку Create, далее тоже вверху выбираем Folder. У нас создалась папка, нажимаем правой кнопкой мыши, по только что созданной папке, у нас появляется большое окно, где необходимо нажать на Rename и ввести желаемое вами название. После создание папки, нам необходимо внутри только что созданной папки создать ещё одну папку с другим названием. После создаём папку Bundles, внутри которой необходимо создать папку Vehicles. Теперь нам необходимо вернуться в начало проекта, и перейти по пути Coremasterbundle/Vehicles, ищем папку с названием Police_German, нажимаем правой кнопкой мыши, по папке Police_German, у нас появляется большое окно в котором нажимаем на пункт ShowInExplorer. У нас открывается проводник, копируем папку Police_German, и переходим в папку Vehicle, которую мы недавно, создавали и вставляем её туда, обязательно не забываем переименовать папку. Закрываем проводник, и обратно переходим в Unity, заходим в ту папку куда скопировали недавно файл.
Кликаем по файлу с названием Vehicle, у вас сменится пространство и появится машина.
Слева мы увидим большое количество объектов, каждый из них я сейчас попробую разобрать для вас.
Игровые объекты у транспорта
Замена сетки
К примеру, нам необходимо заменить модель нашего транспорта. Для этого сперва, нам нужна сама модель, которую мы перед этим должны были создать, или можете взять мою модель [cloud.mail.ru] сделанную специально для руководства. Создаём папку, куда мы будем импортировать наши модели, желательно создавать не в нашей недавно созданной иерархии. С рабочего стола или проводника, перетаскиваем нашу модель в Unity, где мы недавно создали папку, для моделей. Кликаем по модели которую мы скинули, справа открылось окно с настройками модели, ищем параметр Scale Factor, и ставим его на 100. Если модель оказалась больше чем вам нужно, то меняем этот параметр в меньшую сторону. Далее там же переходим во вкладку Materials, если будет параметр sRGB Albedo Colors, то напротив его мы должны убрать галочку, если не будет то просто закрываем. Обратно заходим в наш Vehicle, слева открываем игровой объект у которого бы хотели поменять модель, с компонентами Mesh Filter, и Mesh Render. Не выходя из нашего Vehicle, переходим обратно к нашей модели, там мы увидим небольшую стрелочку, направленную вправо, нажимаем по ней, и у нас раскрывается ваша модель, где будут все материалы и сетка вашей модели. Нажимаем на игровой объект у которого мы хотим поменять модель, и перетаскивает сетку с нашей модели в компонент mesh filter.
Замена материалов
Будет представлено 2 способа. Для тех у кого идёт 1 текстура на всю модель, и для тех у кого много разных материалов в модели.
Для тех у кого текстура
Далее если у вас была текстура, то необходимо создать материал с текстурой. Для этого нужно, перекинуть текстуру, в нашу папку с моделью, нажимаем правой кнопкой мыши по свободно области, у нас выскочит большое окно где необходимо выбрать creat, далее выбрать Material. Нажимаем левой кнопкой, по только что созданному материалу, и перетаскиваем в параметр Albedo нашу текстуру. Далее нажимаем левой кнопкой мыши, по текстуре, и далее с права меняем настройки текстуры. Filter Mode меняем с Bilinear на Point (no Filter), Resize Algorithm меняем с Mitchell на Bilinear, Compression меняем с Normal Quality на None. Далее перетаскиваем наш только что созданный материалов в компонент Mesh Render.
Для тех у кого материалы
Если у вас материалы, то нужно закинуть все ваши текстуры вместе с моделью, если их нет – то это не требуется. Вытаскиваем вашу модель в окно вида, у нас должна появится ваша модель, слева появился новый игровой объект, нажимаем по нему левой кнопкой мыши, справа у него вы наблюдаем 2 компонента Mesh Filter и Mesh Render, нажимаем по компоненту Mesh Render правой кнопкой мыши, выбираем Copy Component, удаляем объект со сцены. Переходим обратно в Vehicle, там мы нажимаем на игровой объект, у которого недавно меняли сетку, видим там компонент Mesh Render, кликаем по нему правой кнопкой мыши, и выбираем Paste Component Values. Можете отдельно создать материал, задать ему цвет, зеркальность, и после перетащить прямо на модель.
Снизу у вас будут появляться якобы ошибки, на них внимания не обращаем !
Заменим для начала наши модели в Model_0, Model_1. Если хотите поменять прорисовку, то нажимаем вот сюда.
Справа вы заметите компонент LOD Group. LOD # отвечает за прорисовку моделей. В LOD 0 находятся модели высокой прорисовки, которые прорисовываются вблизи. Переместив курсор в самый конец LOD 0, и зажав кнопку мыши, можно увеличить его, что увеличит дальность прорисовки LOD 0. LOD 1 отвечает за прорисовку моделей в дали, можно удалить model_1, это не желательно. После чего модели LOD 0 будут прорисовываться на любом расстоянии которое мы настроим, желательно увеличить LOD 0, при удалении LOD1. Теперь нам необходимо, поменять модель в Headlights_Model, и с помощью инструментов, в левом верхнем углу перемещаем, Headlights_Model под нашу модель, рекомендую при вращении зажимать Ctrl. То же самое делаем с Taillights_Model. Если вы хотите сделать мигалки, то меняем модель у Siren_0_Model, Siren_1_Model и так же инструментами в левом верхнем углу, перемещаем под нашу модель машины. Если вам не нужны мигалки, то просто удаляем Siren_0_Model, Siren_1_Model, Sirens. Далее настроим Headlights, перемещаем его поближе к фарам машины, и компонент Lamp который находится в Headlights, передвигаем к одной из фар, также делаем с другим, компонент с прожектером мы перемещаем между фар, и выше к капоту машины. Если вам необходимо больше источников света фар, то нажимаем правой кнопкой мыши, по одному из Lamp, выбираем Duplicate. То же самое делаем с Taillights, только с задними фарами. Зажав Ctrl кликаем по компонентам Fire, Smoke_1, Smoke_0, и перемещаем туда, где у вас должен появляться эффект возгорания. Теперь будем менять модель у колёс, можете оставить стандартные, перед заменой убедитесь, что центр вашей модели, на которую вы будете менять, находится посередине. Раскрываем компонент Wheels, и видим Wheel_#, у каждого из них необходимо поменять, Model_0, Model_1, которые находятся внутри него. Меняем на нашу модель которую мы приготовили. Обязательно надо проверить что у Model_0, Model_1, значения позиции стоят на 0. Это можно посмотреть в компоненте Transform, вращаем чтобы колёса смотрели от стороны машины. Далее после того как поменяли везде модели, выбираем Wheel_#, и каждый из них перемещаем, так как считаем нужным. Теперь раскрываем Tires, и видим там Tire_#, их необходимо переместить, под каждое колесо которое мы недавно настраивали, если радиус Tire маленький, то можете в компоненте Wheel Collider, изменить параметр Radius. Далее раскрываем Objects, и видим seat_#, это модель сиденья, если хотите эту модель заменить. Раскрываем Seats, видим seat_#, на это месте будет игрок при посадке на это место, их мы можем сделать бесконечное количество, но необходимо соблюдать нумерацию. С зажатым ctrl кликаем по seat_#, расположенный у нас в seats, и по тому который находится в Objects. Обязательно соблюдаем нумерацию, чтобы seat_# в seats и в Objects были одинаковы, и при выборе seat_0, выбираем так же steer. Перемещаем наше выделывание, так чтобы это подходило к вашей модели машины. Так делаем со всеми Seat_#. Кликаем по Nav, справа видим компонент Box Collider, это навигация для зомби, животных. Кликаем в этом компоненте на кнопку в пункте Edit Collider, в окне вида мы теперь можем редактировать данный Box Collder. Настраиваем его таким образом, что бы он захватывал полностью модель, и что бы его стенки не уходили далеко от модели машины. Кликаем по Bumper, справа видим компонент Box Collider, настраиваем его таким же способом, как и Nav только не учитываем крышу нашей мышины. Теперь кликаем по игровому объекту Vehicle, который находится в самом верху. Здесь мы видим 2 Box Collider, кликаем по Edit Collider, первый Box Collider мы настраиваем так же как мы настраивали Bumper, 2 под крышу нашей модели.
Как разрабатываются моды для игр, которые не поддерживают моды (на примере Beat Saber) — часть 2: пишем свой мод
В этой части на примере мода для Beat Saber мы рассмотрим общие принципы разработки модов для Unity-игр, узнаем, какие есть трудности, а также познакомимся с Harmony — библиотекой для модификации кода игр, которая используется в RimWorld, Battletech, Cities: Skylines и многих других играх.
Хоть этот лонг и похож на туториал, как написать свой мод для Beat Saber, его цель — показать, какие принципы используются при создании любых пользовательских модов и какие проблемы приходится решать при разработке. Все, что здесь описано, с некоторыми оговорками применимо для всех Unity-игр как минимум в Windows.
Информация из первой части не нужна для понимания того, что будет происходить здесь, но все равно советую с ней ознакомиться.
Вот ее краткое (очень) содержание:
Программные моды (также известные как плагины) — это dll-библиотеки, которые загружаются вместе с игрой и выполняют какой-то код, добавляя в игру новую функциональность или модифицируя существующую. Если у игры нет встроенной поддержки модов, то никакие dll-файлы она запускать не будет. Поэтому для внедрения сторонних модов используются специальные библиотеки, например BepInEx или IPA. В Beat Saber используется BSIPA — улучшенная версия IPA. Сначала ее просто адаптировали специально для Beat Saber, а сейчас она в техническом плане значительно превосходит оригинальную IPA и может использоваться для любых Unity-игр.
Если у вас есть VR-шлем, то вы почти наверняка знаете, что такое Beat Saber. Если нет, то, вероятно, вы видели хотя бы одно видео из игры.
Давайте напишем мод, который показывает время в игре. Он будет показывать текущее время (обычные часы), количество минут, проведенных в игре с ее запуска, и количество минут, активно проведенных в игре, т.е. только время, проведенное в основном геймплее с размахиванием мечей и без учета времени в меню и на паузе.
В этом лонге будет описана полная разработка мода, начиная с создания пустого проекта. Я разбил все на 5 шагов, в конце каждого шага будет краткий вывод об особенностях разработки модов. Если не хотите углубляться в код и детали, то можно просто пробежаться по выводам. Для полного понимания желательно знать основы Unity: работа со сценами, иерархия объектов, компоненты и их жизненный цикл.
Для начала нам нужно сделать так, чтобы игра была пригодна для модов. Для этого в случае с Beat Saber нужно скачать ModAssistant, настроить его (ничего сложного), установить обязательные моды вроде BSIPA, SongCore и BS_Utils и установить другие моды по вкусу. Теперь игра поддерживает моды, а в папках с игрой есть все нужные для нас библиотеки, и можно приступать к разработке.
В случае с другими играми нужно либо искать, что используется у них, либо читать мой прошлый лонг про моды и добавлять поддержку модов самостоятельно.
Все, что написано в данном лонге, работает как минимум для Beat Saber версии 1.9.1 и BSIPA версии 4.0.5. Все развивается и меняется, поэтому если вы читаете этот лонг спустя какое-то время после его публикации, то имейте в виду, что часть информации может устареть.
Начнем с создания проекта и минимального набора сущностей, которые нужны, чтобы можно было добавить наш мод в игру и проверить, что он работает.
Начальные шаги неплохо написаны на сайте Beat Saber Modding Group (далее просто BSMG). К сожалению, только начальные шаги там и описаны. Там предлагается несколько шаблонов Visual Studio для создания проекта на выбор — просто берете, какой нравится и создаете проект из шаблона.
В этом лонге мы пойдем более трудным путем и создадим проект с нуля. Берем любимую среду разработки для C# (у меня Rider), создаем новый C#-проект, выбираем Class Library в качестве целевой сборки и выбираем версию .NET, совместимую с Unity (у меня 4.7.2). Получаем пустой проект. Теперь создаем файлы мода.
Json-файл, содержащий мета-данные для BSIPA. Помечаем его в проекте как EmbeddedResource, чтобы при сборке он добавлялся внутрь нашего dll-файла.
$schema указывает на файл с описанием схемы для валидации формата. Файл лежит на GitHub в репозитории BSIPA. Нас это сильно волновать не должно, просто добавляем и забываем. В dependsOn указываем, какие сторонние моды мы используем в нашем собственном моде. BSIPA использует эту информацию, чтобы определить порядок загрузки dll-файлов.
gameVersion и version используют семантическое версионирование (он же СемВер или SemVer). Подробнее о нем можно прочитать у меня в личном блоге на DTF.
Теперь создаем класс, который будет точкой входа для нашего плагина. В BSIPA 3 нужно было написать класс, реализующий интерфейс IBeatSaberPlugin. BSIPA 3 считывала все классы из dll-файла мода, находила там класс, реализующий интерфейс IBeatSaberPlugin, и создавала объект этого класса — так запускался мод. В BSIPA 4 убрали интерфейс IBeatSaberPlugin. Теперь BSIPA ищет класс, помеченный атрибутом [Plugin], и методы с атрибутами [Init], [OnStart] и [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 и ищем там логи для нашего мода.
Поздравляю, наш мод работает.
Вывод для шага 0: у любого мода должна быть точка входа. Это что-то типа аналога main в обычных программах. Детали реализации зависят от того, как именно работают моды: где-то нужно реализовать интерфейс, где-то использовать атрибуты или аннотации, а где-то просто добавить метод с определенным именем.
На этом шаге сделаем так, чтобы мод делал что-то осмысленное, но еще не трогал код самой игры — добавим часы где-нибудь в углу и покажем время, проведенное в игре с ее запуска. Последуем принципу единственной ответственности и создадим новый класс TimeTracker. Класс Plugin нужен только для запуска и инициализации мода, никакой другой логики там быть не должно.
На этом этапе класс TimeTracker будет создавать canvas в мировом пространстве, добавлять на него два текстовых поля и раз в секунду обновлять на них значения.
Создаем объекты в Awake:
Создаем объект, добавляем на него Canvas, настраиваем его, создаем два текстовых поля. Текстовые поля создаются в CreateText:
Этот метод выглядит громоздко, но, по сути, мы здесь просто создаем объект TextMeshProUGUI и выставляем параметры RectTransform, которые мы в обычном случае установили бы в редакторе Unity.
Тут мы подходим к одному серьезному ограничению при разработке модов для Unity-игр — у нас нет редактора Unity. У нас нет удобного графического интерфейса, и у нас нет сцены, на которой можно накидать все руками и сохранить в префаб — все нужно делать руками из кода. Из-за этого координаты объектов приходится подбирать экспериментально: пробуем какое-нибудь число, запускаем игру, смотрим в каком месте оказался текст. Меняем координаты, перезапускаем игру, смотрим. Повторять, пока текст не окажется там, где нужно.
Чтобы хотя бы примерно понимать, какие координаты должны быть у элементов интерфейса, я сначала вывел на экран 400 текстовых полей: сетку 20 на 20. В каждом поле я выводил его координаты. Это помогло мне начать хоть как-то ориентироваться в координатах и масштабе сцены.
В Update обновляем значения на текстовых полях:
Теперь обновляем наш класс Plugin, чтобы он создавал объект TimeTracker:
Чтобы наш объект жил долго и счастливо и не был убит сборщиком мусора, нужно либо прикрепить его к какой-нибудь существующей сцене в игре, либо вызвать DontDestroyOnLoad(…). Второй способ проще.
Чтобы все это работало, нам нужно добавить библиотеки Unity в список зависимостей проекта: UnityEngine.CoreModule.dll для GameObject и MonoBehaviour, UnityEngine.UI.dll и Unity.TextMeshPro.dll для TextMeshPro и UnityEngine.UIModule.dll для Canvas. Взять их можно все там же, в папке с игрой.
Собираем dll-файл, копируем его в папку с плагинами, запускаем игру и любуемся результатом.
Все отлично, наш мод работает и уже даже приносит пользу. Пока что он живет своей жизнью — он не влияет на игру, а игра не влияет на него. Но из-за этого у нашего мода есть серьезная проблема: он показывает время всегда, даже если оно нам мешает. Например, в самом геймплее. С этим мы разберемся далее.
Вывод из шага 1: у нас нет исходных файлов игры, а значит, ее нельзя открыть в редакторе Unity и пользоваться теми же инструментами, что и при нормальной разработке. Приходится изучать, как все устроено, выводя информацию либо в логи, либо через UI в самой игре.
На этом шаге начинаем контактировать с игрой. Будем считать активное время, проведенное в геймплее, и прятать UI мода, когда он не нужен. Для этого нужно научиться определять переходы из меню в основной геймплей и определять, поставили ли игру на паузу.
Обновляем метод Update. Теперь будем использовать логическую переменную _trackActiveTime, чтобы включать и выключать отслеживание активного времени. Ну и выводим его в новое текстовое поле _activeTimeText. Создаем его так же, как и остальные, просто сдвигаем координаты чуть пониже.
Теперь добавляем метод для включения и выключения отслеживания активного времени:
Здесь мы устанавливаем _trackActiveTime и скрываем текстовые поля. Это заодно решает проблему из прошлого этапа, когда время показывалось в основном геймплее.
Теперь нам нужно каким-то образом сделать так, чтобы основная игра вызывала SetTrackingMode(true), когда мы запускаем какой-то уровень, и SetTrackingMode(false), когда мы возвращаемся в меню или ставим игру на паузу. Проще всего это сделать через события. Для начала пойдем простым путем и добавим мод, который упрощает взаимодействие с игрой, а потом уже посмотрим, как это делается руками.
Нам нужен мод BS_Utils. Добавляем в список зависимостей проекта библиотеку BS_Utils.dll из папки Beat Saber/Plugins (мы ее установили когда ставили моды через ModAssistant). Теперь добавляем BS_Utils в манифест. Это нужно для того, чтобы наш мод загружался после него.
Находим в событиях BS_Utils те, которые нам нужны, подписываемся на них и переключаем отслеживание активного времени.
Методы EnableTrackingMode и DisableTrackingMode я добавил просто для удобства, чтобы можно было их использовать как делегаты в событиях без аргументов.
Собираем проект, копируем dll в Plugins, запускаем игру, проверяем.
Если бы мы просто разрабатывали мод для Beat Saber, то на этом этапе можно было бы и остановиться. Мод готов, он делает то, что мы хотели, и так, как мы хотели. Он использует сторонний мод BS_Utils, но почти все моды используют его. BS_Utils поддерживается одним из главных разработчиков в сообществе BSMG, так что не нужно переживать, что в какой-то момент он перестанет работать. Но это познавательный лонг, поэтому мы пойдем дальше. И мы еще не все разобрали, что нужно для разработки модов.
Вывод из шага 2: если у игры большое сообщество моддеров, то, скорее всего, они уже сделали многое, чтобы облегчить работу друг другу. Например, в Beat Saber мод BS_Utils значительно упрощает работу с кодом игры, а BSML — это мод, позволяющий создавать графический интерфейс с помощью xml-конфигураций.
Удаляем BS_Utils из зависимостей проекта и из манифеста. Компилятор сообщает нам, что BSEvents и его события теперь не определены. Их мы и будем заменять на этом шаге.
Эти события срабатывают, когда активируется сцена с меню и сцена с основным геймплеем соответственно. Для работы со сценами у Unity есть статический класс SceneManager, у которого есть события sceneLoaded, sceneUnloaded и activeSceneChanged. Добавляем обработчики событий для них и просто выводим названия сцен в логи. Так как мы уже добавили библиотеку UnityEngine.CoreModule.dll в зависимости, проблем с определением SceneManager быть не должно.
Собираем мод, запускаем игру, заходим в основной геймплей, выходим из него, выходим из игры, смотрим логи.
Здесь так много разных сцен, потому что Beat Saber использует разные сцены для разных компонентов и загружает их в режиме Additive. Интерфейс на одной сцене, платформа с игроком — на другой. Анализируем логи и делаем вывод: отслеживать переход в основной геймплей можно, например, при активации сцены GameCore. По аналогии, переход в меню — по активации сцены MenuCore. Но с MenuCore есть проблема — судя по логам, она не активируется при запуске игры, когда мы только попадаем в меню. Поэтому для меню лучше использовать сцену MenuViewControllers. Еще одно полезное наблюдение: сцены для меню загружаются один раз при запуске игры и просто деактивируются при запуске геймплея, а вот сцены геймплея загружаются заново при запуске уровня. Это нам еще пригодится.
Обновляем OnActiveSceneChanged: проверяем имя сцены и переключаем отслеживание активного времени:
Для следующих событий придется покопаться в коде игры, поэтому переходим к настоящему реверс-инжинирингу. Теперь нам нужна библиотека, в которой содержится код Beat Saber. В папке «Beat Saber/Beat Saber_Data/Managed» лежат 2 библиотеки: Main.dll и MainAssembly.dll. Я сначала копался в MainAssembly.dll, из-за чего потратил 2 дня на отладку одного очень странного поведения. Подробнее об этом я писал у себя в личном блоге.
Судя по тому, что я узнал и посмотрел в других модах, все, что нам нужно, лежит в библиотеке Main.dll. Нам нужно посмотреть ее содержимое, а для этого нужен декомпилятор. На сайте BSMG советуют использовать dnSpy. Я использую Rider в качестве среды разработки, и у него есть встроенный декомпилятор, поэтому про dnSpy ничего конкретного сказать не могу, не пользовался. Но, судя по описанию, вещь полезная — это не только декомпилятор, но еще и дебаггер, который может подключаться к Unity-процессам.
Дальше идет рутина: берем содержимое Main.dll и ищем класс, который делает то, что нам нужно. Это сложно, но по-другому никак. Разве что можно пойти в дискорд BSMG и спросить. Вам, скорее всего, ответят, потому что там много людей, которые уже когда-то декомпилировали Main.dll и что-то там искали (и нашли).
Рано или поздно мы найдем класс GamePause, который отвечает в игре за включение и выключение паузы. У него есть два метода: Pause и Resume. А еще у GamePause есть два события: didPauseEvent и didResumeEvent. Отлично, нам даже не пришлось делать что-то сложное, у GamePause уже есть события, на которые мы можем подписаться.
Значит, нам каким-то образом нужно получить ссылку на компонент GamePause. В Unity это можно сделать так:
Этому методу все равно, на какой сцене компонент, что за объект и активен ли он. Если компонент создан, он будет найден. Но нужно как-то найти момент времени, когда этот компонент создан. Можно предположить, что он висит на каком-то объекте на одной из сцен в геймплее. Мы уже выяснили, что геймплейные сцены каждый раз создаются заново. У нас есть обработчики событий OnSceneLoaded и OnActiveSceneChanged, поэтому мы можем отловить там сцену GameCore и в этот момент попробовать получить ссылку на GamePause. Проблема в том, что он может создаваться динамически чуть позже, чем загружаются сцены, поэтому тут есть два варианта: поискать в игре событие, которое срабатывает после того, как GamePause создан (вряд ли такое есть), либо вызывать Resources.FindObjectsOfTypeAll каждый кадр, пока не найдем компонент. Например, через корутину:
Запускаем ее в OnActiveSceneChanged для сцены GameCore:
Собираем мод, запускаем игру и убеждаемся, что все работает. Также можно заглянуть в логи. Там видно, что GamePause существует сразу же после активации GameCore, а значит, корутина не нужна и можно ее убрать. Я решил оставить для надежности.
Вывод из шага 3: чтобы сделать мод для игры, нужно знать ее архитектуру и исходный код. Для этого приходится много времени тратить с декомпилятором, копаясь в исходном коде и пытаясь понять, как там все устроено. А копаться в чужом коде не всегда легко и приятно.
На этом этапе начинается магия, мы взглянем на Harmony — библиотеку для модификации C#-кода, которая используется моддерами во многих играх. Ее автор — Andreas Pardeike (сайт, GitHub), работает ведущим iOS-разработчиком / архитектором в шведской полиции (Swedish Police Authority). В отличие от библиотеки Mono.Cecil из прошлого лонга, которая модифицирует и перезаписывает dll-файлы с .NET-сборками, Harmony модифицирует код во время исполнения программы (runtime). Модифицировать можно только методы, что обычно достаточно, так как нам нужно модифицировать именно поведение, а не состояние. Для модификации состояния есть много других способов, в том числе стандартных.
Модификации Harmony в терминах самой библиотеки называются патчами (patches). Есть несколько видов патчей:
Prefix. Патч, который вызывается перед выполнением метода. С его помощью можно перехватить и изменить аргументы метода, либо решить, нужно ли вызывать сам метод или сразу выйти из него.
Transpiler. Патч, который на ходу модифицирует скомпилированный IL-код. Можно использовать, если нужно изменить логику где-то в середине метода.
Самые популярные патчи — это Prefix и Postfix. Transpiler слишком сложный, так как это уже не C#, а IL-код, да и зачастую проще скопировать исходный метод через декомпилятор, изменить там что-то и заменить весь метод через Prefix/Postfix. Finalizer звучит полезно, но он появился только недавно, в Harmony 2.0, поэтому примеров его использования я еще не видел.
Когда я только придумывал идею для мода, я думал, что Harmony мне понадобится сразу же, как только я решу убрать BS_Utils. Оказалось, что GamePause сам по себе содержит все нужные события, и теперь придется искусственно усложнить задачу, чтобы показать, как работает Harmony. Давайте представим, что в GamePause нет событий didPauseEvent и didResumeEvent, и нам нужно что-то с этим сделать.
Так как мы все еще придерживаемся принципа единственной ответственности, создаем класс HarmonyPatcher. У него будет всего один метод: public static void ApplyPatches() <>, в котором будет примерно такой код:
Этих двух строк достаточно, чтобы установить все патчи, который у нас есть (но их пока нет). «com.fck_r_sns.BeatSaberTimeTracker» — это имя пакета. Оно должно быть уникальным, чтобы не было коллизий с патчами из других модов. Теперь идем в класс Plugin, который у нас отвечает за старт и инициализацию мода, и добавляем туда вызов HarmonyPatcher.ApplyPatches() перед созданием TimeTracker.
Переходим к написанию самих патчей. Для каждого метода, который мы хотим модифицировать, нужно написать отдельный класс. Каждый патч — это статический метод в этом классе. Чтобы указать, что это за патч, мы можем либо использовать соответствующее имя метода (например, метод с именем Prefix — это Prefix-патч), либо использовать любые имена и помечать методы атрибутами (например, [HarmonyPrefix]). Я всегда предпочитаю, чтобы код был явным и легко читаемым, поэтому я сторонник подхода с атрибутами. Начнем с патчей для метода GamePause.Pause(). Добавим в него Postfix-патч, который просто пишет в лог, что был вызван метод Pause() и сработал Postfix-патч.
Атрибут [HarmonyPatch] указывает, какие класс и метод нам нужно модифицировать. Статический метод TestPostfixPatch помечен атрибутом [HarmonyPostfix], поэтому это Postfix-патч. Создаем аналогичный класс для GamePause.Resume() (можно в том же файле), собираем, запускаем игру, запускаем уровень, жмем паузу, снимаем паузу, выходим из игры, проверяем логи.
Проверяем, что патчи применились:
Проверяем, что Postfix-патчи сработали:
Отлично, Harmony работает, можно переходить к логике. В нашем искусственном примере мы представили, что событий didPauseEvent и didResumeEvent не существует, а значит, нам нужно в Postfix-патчах что-то сделать, чтобы TimeTracker включал и выключал отслеживание активного времени. Тут мы натыкаемся на главную проблему Harmony — все патчи являются статическими методами. А TimeTracker — это компонент, который висит где-то в иерархии объектов и статическим явно не является. Тут я вижу два нормальных решения этой задачи.
Первый — это сделать TimeTracker доступным из статического контекста. Например, сделать его синглтоном или каждый раз получать на него ссылку через Resources.FindObjectsOfTypeAll(). В BS_Utils, например, используется синглтон.
Второй — это добавить класс со статическими событиями вроде BS_Utils.Utilities.BSEvents, который мы использовали на ранних этапах. Этот вариант мне нравится больше, давайте реализовывать его.
Создаем класс EventsHelper:
Теперь обновляем наши патчи, чтобы они вызывали эти события:
GamePauseResumePatch делается аналогично. Пришлось добавить публичные методы FireOnGamePausedEvent и FireOnGameResumedEvent, так как нельзя вызывать события из-за пределов их класса. Теперь TimeTracker может в любой момент подписаться на события в EventsHelper. Получаем код со слабым зацеплением — именно из-за этого подход с событиями мне нравится больше, чем вариант с синглтоном или Resources.FindObjectsOfTypeAll().
Если мы соберем мод и запустим игру, то все будет работать. Однако, мы пока не учли одну деталь. В оригинальном коде GamePause.Pause() есть проверка от многократного перехода в режим паузы.
Postfix-патч же будет вызван в любом случае: и если мы установили паузу, и если это было повторное нажатие. А значит, и событие EventsHelper будет срабатывать всегда, даже если фактического перехода в паузу уже не было. Давайте добавим Prefix-патч, в котором будем проверять текущее состояние паузы. Harmony позволяет читать и изменять приватные переменные класса, а также передавать состояние между патчами одного метода. В Harmony вообще много чего можно получить в патче:
- Аргументы метода: собственно то, что было передано в метод при его вызове.
- __instance: ссылка на текущий объект, для которого вызван метод. По сути это просто this.
- __state: переменная любого типа для передачи состояния между патчами. Если нужно несколько переменных, то просто пишем структуру или класс.
- __result: возвращаемый результат оригинального метода. Если нужно, можно его изменить.
- Приватные переменные: добавляем три (3) знака подчеркивания (_) перед названием аргумента в патче, и Harmony подставит туда значение из приватной переменной.
Начнем со структуры, которая будет хранить состояние:
Нам нужно всего одно значение, чтобы отслеживать состояние паузы, поэтому структура избыточна, но как я уже писал выше, я люблю ясный код. PauseState __state — это более ясный код, чем просто bool __state.
Теперь добавляем Prefix-патч:
Здесь мы добавляем состояние с модификатором out, чтобы его можно было изменять, и приватную переменную ____pause (_pause и еще три подчеркивания перед ней). Просто сохраняем ____pause в __state — тут ничего хитрого.
Теперь обновляем Postfx-патч:
__state даст нам ту же структуру, которую мы записали в Prefix-патче. Сравниваем wasPaused с ____pause, чтобы проверить, что игра реально поставлена на паузу и вызываем событие.
Запускаем игру и проверяем, что все работает.
Вывод из шага 4: Harmony круто. Это очень полезная и важная для сообщества моддеров библиотека, которая используется в RimWorld, Battletech, Cities: Skylines, Kerbal Space Program, Oxygen Not Included, Stardew Valley, Subnautica и многих других играх.
Создание модов — это довольно утомительный процесс. Теперь вы знаете, что при разработке модов нужно постоянно копаться в декомпилированном коде, искать классы, которые делают то, что вам нужно, модифицировать их, постоянно пересобирать моды, чтобы проверить изменения в игре, страдать из-за отсутствия нормального режима отладки и полноценного Unity-редактора.
А потом разработчики выпускают новую версию игры, в которой они поменяли логику, которая использовалась в моде, и нужно делать все сначала.
Подписывайтесь на блог. Там я пишу про различные истории из геймдева, про технологии и про когнитивные искажения.
Unity как создать аддон для своей игры
Добавление скриптинга и динамических аддонов в Unity (часть 1)
Крафтим уточку, или как самому сделать мод для The Long Dark
Конфигурация предмета и создание Asset Bundle в Unity Editor
- ClothingPaperDoll — папка для текстур, которые будут использоваться при отображении на кукле персонажа в меню одежды.
- Editor — эта папка должна содержать скрипт InventoryGridItems — папка для текстур иконок предмета в инвентаре.
- Models — папка для импорта 3d модели, материалов к ней, текстур, и шейдеров и т.п.
- Plugins — эта папка должна содержать внешнюю библиотеку в формате dll которая как раз и позволяет добавлять Components из ModComponents в редактор для наделения предметов свойствами характерными для механики игры.
- Prefabs — папка в которую следует помещать готовые Localization.csv — файл в формате CSV, используется игрой для текстов и описаний предметов.
Mod Generic Item Component.
Description
Inventory
Basic Properties
Inspecting
Mod Tool Component
Implementation
ModClothingComponent
Food/Decay
Food/Calories
Food/Eating
Food/Food Poisoning
Food/Type
Food/Opening
Food/Fatigue
Food/Cold
Food/Alcohol
Mod Rifle Component
Rifle/General
Rifle/Timing
Rifle/Sway
Mod Harvestable Component
Mod Repairable Component
Mod Fire Starter Component
Mod Burnable Component
Mod Accelerant Component
Mod Blueprint
- После этого, если в папке с игрой еще нет папки «TheLongDark/mods/auto-mapped», то ее нужно создать.
- Затем скопируйте получившийся файл asset bundle с расширением «.unity3d» из папки «AssetBundles» проекта Unity в папку «…/TheLongDark/mods/auto-mapped».
Вы можете создать свои собственные подпапки в папке «…/auto-mapped» с тем чтобы не запутаться в ваших модах
- создайте файл — карту с указанием мест где появится ваш предмет при запуске новой игры. Это обычный текстовый файл с расширением «.txt» в папке «…/TheLongDark/mods/gear-spawns» (как вы его назовете неважно) в котором Если вы не будете создавать такой файл, то заполучить предмет в игре вы сможете только через консольную команду additem.
- создать файл конфигурации чертежей для крафтовых предметов также по Если вы не создадите такой файл конфигурации для крафтового предмета, то единственная возможность получить его в игре будет или вызвать его из консоли, или просто найти.
- В случае, если вы наделили предмет несвойственной игре механикой, то вам также нужно переместить прочие ресурсы (файлы DLL, файлы звуков, и т.п.) в папку «auto-mapped». Если у вас предмет со стандартным функционалом, то это не понадобится.
- выйдите из The Long Dark
- вернитесь обратно в редактор Unity и внесите исправления в prefab игрового объекта или иконку в «InventoryGridIcons» или в файл локализации «Localization.csv» или другое нужное место.
- выполните заново экспорт asset bundle, путем запуска скрипта
- и скопируйте вновь получившийся файл «.unity3d» с заменой старого файла на новый в папку вашего мода в «…/TheLongDark/mods/auto-mapped»
- запустите игру и снова всё проверьте.
Unity как создать аддон для своей игры

1
1
Как разрабатываются моды для игр, которые не поддерживают моды (на примере Beat Saber) — часть 2: пишем свой мод
В этой части на примере мода для Beat Saber мы рассмотрим общие принципы разработки модов для Unity-игр, узнаем, какие есть трудности, а также познакомимся с Harmony — библиотекой для модификации кода игр, которая используется в RimWorld, Battletech, Cities: Skylines и многих других играх.
Хоть этот лонг и похож на туториал, как написать свой мод для Beat Saber, его цель — показать, какие принципы используются при создании любых пользовательских модов и какие проблемы приходится решать при разработке. Все, что здесь описано, с некоторыми оговорками применимо для всех Unity-игр как минимум в Windows.
Информация из первой части не нужна для понимания того, что будет происходить здесь, но все равно советую с ней ознакомиться.
Вот ее краткое (очень) содержание:
Программные моды (также известные как плагины) — это dll-библиотеки, которые загружаются вместе с игрой и выполняют какой-то код, добавляя в игру новую функциональность или модифицируя существующую. Если у игры нет встроенной поддержки модов, то никакие dll-файлы она запускать не будет. Поэтому для внедрения сторонних модов используются специальные библиотеки, например BepInEx или IPA. В Beat Saber используется BSIPA — улучшенная версия IPA. Сначала ее просто адаптировали специально для Beat Saber, а сейчас она в техническом плане значительно превосходит оригинальную IPA и может использоваться для любых Unity-игр.
Если у вас есть VR-шлем, то вы почти наверняка знаете, что такое Beat Saber. Если нет, то, вероятно, вы видели хотя бы одно видео из игры.
Давайте напишем мод, который показывает время в игре. Он будет показывать текущее время (обычные часы), количество минут, проведенных в игре с ее запуска, и количество минут, активно проведенных в игре, т.е. только время, проведенное в основном геймплее с размахиванием мечей и без учета времени в меню и на паузе.
В этом лонге будет описана полная разработка мода, начиная с создания пустого проекта. Я разбил все на 5 шагов, в конце каждого шага будет краткий вывод об особенностях разработки модов. Если не хотите углубляться в код и детали, то можно просто пробежаться по выводам. Для полного понимания желательно знать основы Unity: работа со сценами, иерархия объектов, компоненты и их жизненный цикл.
Для начала нам нужно сделать так, чтобы игра была пригодна для модов. Для этого в случае с Beat Saber нужно скачать ModAssistant, настроить его (ничего сложного), установить обязательные моды вроде BSIPA, SongCore и BS_Utils и установить другие моды по вкусу. Теперь игра поддерживает моды, а в папках с игрой есть все нужные для нас библиотеки, и можно приступать к разработке.
В случае с другими играми нужно либо искать, что используется у них, либо читать мой прошлый лонг про моды и добавлять поддержку модов самостоятельно.
Все, что написано в данном лонге, работает как минимум для Beat Saber версии 1.9.1 и BSIPA версии 4.0.5. Все развивается и меняется, поэтому если вы читаете этот лонг спустя какое-то время после его публикации, то имейте в виду, что часть информации может устареть.
Начнем с создания проекта и минимального набора сущностей, которые нужны, чтобы можно было добавить наш мод в игру и проверить, что он работает.
Начальные шаги неплохо написаны на сайте Beat Saber Modding Group (далее просто BSMG). К сожалению, только начальные шаги там и описаны. Там предлагается несколько шаблонов Visual Studio для создания проекта на выбор — просто берете, какой нравится и создаете проект из шаблона.
В этом лонге мы пойдем более трудным путем и создадим проект с нуля. Берем любимую среду разработки для C# (у меня Rider), создаем новый C#-проект, выбираем Class Library в качестве целевой сборки и выбираем версию .NET, совместимую с Unity (у меня 4.7.2). Получаем пустой проект. Теперь создаем файлы мода.
Json-файл, содержащий мета-данные для BSIPA. Помечаем его в проекте как EmbeddedResource, чтобы при сборке он добавлялся внутрь нашего dll-файла.
$schema указывает на файл с описанием схемы для валидации формата. Файл лежит на GitHub в репозитории BSIPA. Нас это сильно волновать не должно, просто добавляем и забываем. В dependsOn указываем, какие сторонние моды мы используем в нашем собственном моде. BSIPA использует эту информацию, чтобы определить порядок загрузки dll-файлов.
gameVersion и version используют семантическое версионирование (он же СемВер или SemVer). Подробнее о нем можно прочитать у меня в личном блоге на DTF.
Теперь создаем класс, который будет точкой входа для нашего плагина. В BSIPA 3 нужно было написать класс, реализующий интерфейс IBeatSaberPlugin. BSIPA 3 считывала все классы из dll-файла мода, находила там класс, реализующий интерфейс IBeatSaberPlugin, и создавала объект этого класса — так запускался мод. В BSIPA 4 убрали интерфейс IBeatSaberPlugin. Теперь BSIPA ищет класс, помеченный атрибутом [Plugin], и методы с атрибутами [Init], [OnStart] и [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 и ищем там логи для нашего мода.
Поздравляю, наш мод работает.
Вывод для шага 0: у любого мода должна быть точка входа. Это что-то типа аналога main в обычных программах. Детали реализации зависят от того, как именно работают моды: где-то нужно реализовать интерфейс, где-то использовать атрибуты или аннотации, а где-то просто добавить метод с определенным именем.
На этом шаге сделаем так, чтобы мод делал что-то осмысленное, но еще не трогал код самой игры — добавим часы где-нибудь в углу и покажем время, проведенное в игре с ее запуска. Последуем принципу единственной ответственности и создадим новый класс TimeTracker. Класс Plugin нужен только для запуска и инициализации мода, никакой другой логики там быть не должно.
На этом этапе класс TimeTracker будет создавать canvas в мировом пространстве, добавлять на него два текстовых поля и раз в секунду обновлять на них значения.
Создаем объекты в Awake:
Создаем объект, добавляем на него Canvas, настраиваем его, создаем два текстовых поля. Текстовые поля создаются в CreateText:
Этот метод выглядит громоздко, но, по сути, мы здесь просто создаем объект TextMeshProUGUI и выставляем параметры RectTransform, которые мы в обычном случае установили бы в редакторе Unity.
Тут мы подходим к одному серьезному ограничению при разработке модов для Unity-игр — у нас нет редактора Unity. У нас нет удобного графического интерфейса, и у нас нет сцены, на которой можно накидать все руками и сохранить в префаб — все нужно делать руками из кода. Из-за этого координаты объектов приходится подбирать экспериментально: пробуем какое-нибудь число, запускаем игру, смотрим в каком месте оказался текст. Меняем координаты, перезапускаем игру, смотрим. Повторять, пока текст не окажется там, где нужно.
Чтобы хотя бы примерно понимать, какие координаты должны быть у элементов интерфейса, я сначала вывел на экран 400 текстовых полей: сетку 20 на 20. В каждом поле я выводил его координаты. Это помогло мне начать хоть как-то ориентироваться в координатах и масштабе сцены.
В Update обновляем значения на текстовых полях:
Теперь обновляем наш класс Plugin, чтобы он создавал объект TimeTracker:
Чтобы наш объект жил долго и счастливо и не был убит сборщиком мусора, нужно либо прикрепить его к какой-нибудь существующей сцене в игре, либо вызвать DontDestroyOnLoad(…). Второй способ проще.
Чтобы все это работало, нам нужно добавить библиотеки Unity в список зависимостей проекта: UnityEngine.CoreModule.dll для GameObject и MonoBehaviour, UnityEngine.UI.dll и Unity.TextMeshPro.dll для TextMeshPro и UnityEngine.UIModule.dll для Canvas. Взять их можно все там же, в папке с игрой.
Собираем dll-файл, копируем его в папку с плагинами, запускаем игру и любуемся результатом.
Все отлично, наш мод работает и уже даже приносит пользу. Пока что он живет своей жизнью — он не влияет на игру, а игра не влияет на него. Но из-за этого у нашего мода есть серьезная проблема: он показывает время всегда, даже если оно нам мешает. Например, в самом геймплее. С этим мы разберемся далее.
Вывод из шага 1: у нас нет исходных файлов игры, а значит, ее нельзя открыть в редакторе Unity и пользоваться теми же инструментами, что и при нормальной разработке. Приходится изучать, как все устроено, выводя информацию либо в логи, либо через UI в самой игре.
На этом шаге начинаем контактировать с игрой. Будем считать активное время, проведенное в геймплее, и прятать UI мода, когда он не нужен. Для этого нужно научиться определять переходы из меню в основной геймплей и определять, поставили ли игру на паузу.
Обновляем метод Update. Теперь будем использовать логическую переменную _trackActiveTime, чтобы включать и выключать отслеживание активного времени. Ну и выводим его в новое текстовое поле _activeTimeText. Создаем его так же, как и остальные, просто сдвигаем координаты чуть пониже.
Теперь добавляем метод для включения и выключения отслеживания активного времени:
Здесь мы устанавливаем _trackActiveTime и скрываем текстовые поля. Это заодно решает проблему из прошлого этапа, когда время показывалось в основном геймплее.
Теперь нам нужно каким-то образом сделать так, чтобы основная игра вызывала SetTrackingMode(true), когда мы запускаем какой-то уровень, и SetTrackingMode(false), когда мы возвращаемся в меню или ставим игру на паузу. Проще всего это сделать через события. Для начала пойдем простым путем и добавим мод, который упрощает взаимодействие с игрой, а потом уже посмотрим, как это делается руками.
Нам нужен мод BS_Utils. Добавляем в список зависимостей проекта библиотеку BS_Utils.dll из папки Beat Saber/Plugins (мы ее установили когда ставили моды через ModAssistant). Теперь добавляем BS_Utils в манифест. Это нужно для того, чтобы наш мод загружался после него.
Находим в событиях BS_Utils те, которые нам нужны, подписываемся на них и переключаем отслеживание активного времени.
Методы EnableTrackingMode и DisableTrackingMode я добавил просто для удобства, чтобы можно было их использовать как делегаты в событиях без аргументов.
Собираем проект, копируем dll в Plugins, запускаем игру, проверяем.
Если бы мы просто разрабатывали мод для Beat Saber, то на этом этапе можно было бы и остановиться. Мод готов, он делает то, что мы хотели, и так, как мы хотели. Он использует сторонний мод BS_Utils, но почти все моды используют его. BS_Utils поддерживается одним из главных разработчиков в сообществе BSMG, так что не нужно переживать, что в какой-то момент он перестанет работать. Но это познавательный лонг, поэтому мы пойдем дальше. И мы еще не все разобрали, что нужно для разработки модов.
Вывод из шага 2: если у игры большое сообщество моддеров, то, скорее всего, они уже сделали многое, чтобы облегчить работу друг другу. Например, в Beat Saber мод BS_Utils значительно упрощает работу с кодом игры, а BSML — это мод, позволяющий создавать графический интерфейс с помощью xml-конфигураций.
Удаляем BS_Utils из зависимостей проекта и из манифеста. Компилятор сообщает нам, что BSEvents и его события теперь не определены. Их мы и будем заменять на этом шаге.
Эти события срабатывают, когда активируется сцена с меню и сцена с основным геймплеем соответственно. Для работы со сценами у Unity есть статический класс SceneManager, у которого есть события sceneLoaded, sceneUnloaded и activeSceneChanged. Добавляем обработчики событий для них и просто выводим названия сцен в логи. Так как мы уже добавили библиотеку UnityEngine.CoreModule.dll в зависимости, проблем с определением SceneManager быть не должно.
Собираем мод, запускаем игру, заходим в основной геймплей, выходим из него, выходим из игры, смотрим логи.
Здесь так много разных сцен, потому что Beat Saber использует разные сцены для разных компонентов и загружает их в режиме Additive. Интерфейс на одной сцене, платформа с игроком — на другой. Анализируем логи и делаем вывод: отслеживать переход в основной геймплей можно, например, при активации сцены GameCore. По аналогии, переход в меню — по активации сцены MenuCore. Но с MenuCore есть проблема — судя по логам, она не активируется при запуске игры, когда мы только попадаем в меню. Поэтому для меню лучше использовать сцену MenuViewControllers. Еще одно полезное наблюдение: сцены для меню загружаются один раз при запуске игры и просто деактивируются при запуске геймплея, а вот сцены геймплея загружаются заново при запуске уровня. Это нам еще пригодится.
Обновляем OnActiveSceneChanged: проверяем имя сцены и переключаем отслеживание активного времени:
Для следующих событий придется покопаться в коде игры, поэтому переходим к настоящему реверс-инжинирингу. Теперь нам нужна библиотека, в которой содержится код Beat Saber. В папке «Beat Saber/Beat Saber_Data/Managed» лежат 2 библиотеки: Main.dll и MainAssembly.dll. Я сначала копался в MainAssembly.dll, из-за чего потратил 2 дня на отладку одного очень странного поведения. Подробнее об этом я писал у себя в личном блоге.
Судя по тому, что я узнал и посмотрел в других модах, все, что нам нужно, лежит в библиотеке Main.dll. Нам нужно посмотреть ее содержимое, а для этого нужен декомпилятор. На сайте BSMG советуют использовать dnSpy. Я использую Rider в качестве среды разработки, и у него есть встроенный декомпилятор, поэтому про dnSpy ничего конкретного сказать не могу, не пользовался. Но, судя по описанию, вещь полезная — это не только декомпилятор, но еще и дебаггер, который может подключаться к Unity-процессам.
Дальше идет рутина: берем содержимое Main.dll и ищем класс, который делает то, что нам нужно. Это сложно, но по-другому никак. Разве что можно пойти в дискорд BSMG и спросить. Вам, скорее всего, ответят, потому что там много людей, которые уже когда-то декомпилировали Main.dll и что-то там искали (и нашли).
Рано или поздно мы найдем класс GamePause, который отвечает в игре за включение и выключение паузы. У него есть два метода: Pause и Resume. А еще у GamePause есть два события: didPauseEvent и didResumeEvent. Отлично, нам даже не пришлось делать что-то сложное, у GamePause уже есть события, на которые мы можем подписаться.
Значит, нам каким-то образом нужно получить ссылку на компонент GamePause. В Unity это можно сделать так:
Этому методу все равно, на какой сцене компонент, что за объект и активен ли он. Если компонент создан, он будет найден. Но нужно как-то найти момент времени, когда этот компонент создан. Можно предположить, что он висит на каком-то объекте на одной из сцен в геймплее. Мы уже выяснили, что геймплейные сцены каждый раз создаются заново. У нас есть обработчики событий OnSceneLoaded и OnActiveSceneChanged, поэтому мы можем отловить там сцену GameCore и в этот момент попробовать получить ссылку на GamePause. Проблема в том, что он может создаваться динамически чуть позже, чем загружаются сцены, поэтому тут есть два варианта: поискать в игре событие, которое срабатывает после того, как GamePause создан (вряд ли такое есть), либо вызывать Resources.FindObjectsOfTypeAll каждый кадр, пока не найдем компонент. Например, через корутину:
Запускаем ее в OnActiveSceneChanged для сцены GameCore:
Собираем мод, запускаем игру и убеждаемся, что все работает. Также можно заглянуть в логи. Там видно, что GamePause существует сразу же после активации GameCore, а значит, корутина не нужна и можно ее убрать. Я решил оставить для надежности.
Вывод из шага 3: чтобы сделать мод для игры, нужно знать ее архитектуру и исходный код. Для этого приходится много времени тратить с декомпилятором, копаясь в исходном коде и пытаясь понять, как там все устроено. А копаться в чужом коде не всегда легко и приятно.
На этом этапе начинается магия, мы взглянем на Harmony — библиотеку для модификации C#-кода, которая используется моддерами во многих играх. Ее автор — Andreas Pardeike (сайт, GitHub), работает ведущим iOS-разработчиком / архитектором в шведской полиции (Swedish Police Authority). В отличие от библиотеки Mono.Cecil из прошлого лонга, которая модифицирует и перезаписывает dll-файлы с .NET-сборками, Harmony модифицирует код во время исполнения программы (runtime). Модифицировать можно только методы, что обычно достаточно, так как нам нужно модифицировать именно поведение, а не состояние. Для модификации состояния есть много других способов, в том числе стандартных.
Модификации Harmony в терминах самой библиотеки называются патчами (patches). Есть несколько видов патчей:
Prefix. Патч, который вызывается перед выполнением метода. С его помощью можно перехватить и изменить аргументы метода, либо решить, нужно ли вызывать сам метод или сразу выйти из него.
Transpiler. Патч, который на ходу модифицирует скомпилированный IL-код. Можно использовать, если нужно изменить логику где-то в середине метода.
Самые популярные патчи — это Prefix и Postfix. Transpiler слишком сложный, так как это уже не C#, а IL-код, да и зачастую проще скопировать исходный метод через декомпилятор, изменить там что-то и заменить весь метод через Prefix/Postfix. Finalizer звучит полезно, но он появился только недавно, в Harmony 2.0, поэтому примеров его использования я еще не видел.
Когда я только придумывал идею для мода, я думал, что Harmony мне понадобится сразу же, как только я решу убрать BS_Utils. Оказалось, что GamePause сам по себе содержит все нужные события, и теперь придется искусственно усложнить задачу, чтобы показать, как работает Harmony. Давайте представим, что в GamePause нет событий didPauseEvent и didResumeEvent, и нам нужно что-то с этим сделать.
Так как мы все еще придерживаемся принципа единственной ответственности, создаем класс HarmonyPatcher. У него будет всего один метод: public static void ApplyPatches() <>, в котором будет примерно такой код:
Этих двух строк достаточно, чтобы установить все патчи, который у нас есть (но их пока нет). «com.fck_r_sns.BeatSaberTimeTracker» — это имя пакета. Оно должно быть уникальным, чтобы не было коллизий с патчами из других модов. Теперь идем в класс Plugin, который у нас отвечает за старт и инициализацию мода, и добавляем туда вызов HarmonyPatcher.ApplyPatches() перед созданием TimeTracker.
Переходим к написанию самих патчей. Для каждого метода, который мы хотим модифицировать, нужно написать отдельный класс. Каждый патч — это статический метод в этом классе. Чтобы указать, что это за патч, мы можем либо использовать соответствующее имя метода (например, метод с именем Prefix — это Prefix-патч), либо использовать любые имена и помечать методы атрибутами (например, [HarmonyPrefix]). Я всегда предпочитаю, чтобы код был явным и легко читаемым, поэтому я сторонник подхода с атрибутами. Начнем с патчей для метода GamePause.Pause(). Добавим в него Postfix-патч, который просто пишет в лог, что был вызван метод Pause() и сработал Postfix-патч.
Атрибут [HarmonyPatch] указывает, какие класс и метод нам нужно модифицировать. Статический метод TestPostfixPatch помечен атрибутом [HarmonyPostfix], поэтому это Postfix-патч. Создаем аналогичный класс для GamePause.Resume() (можно в том же файле), собираем, запускаем игру, запускаем уровень, жмем паузу, снимаем паузу, выходим из игры, проверяем логи.
Проверяем, что патчи применились:
Проверяем, что Postfix-патчи сработали:
Отлично, Harmony работает, можно переходить к логике. В нашем искусственном примере мы представили, что событий didPauseEvent и didResumeEvent не существует, а значит, нам нужно в Postfix-патчах что-то сделать, чтобы TimeTracker включал и выключал отслеживание активного времени. Тут мы натыкаемся на главную проблему Harmony — все патчи являются статическими методами. А TimeTracker — это компонент, который висит где-то в иерархии объектов и статическим явно не является. Тут я вижу два нормальных решения этой задачи.
Первый — это сделать TimeTracker доступным из статического контекста. Например, сделать его синглтоном или каждый раз получать на него ссылку через Resources.FindObjectsOfTypeAll(). В BS_Utils, например, используется синглтон.
Второй — это добавить класс со статическими событиями вроде BS_Utils.Utilities.BSEvents, который мы использовали на ранних этапах. Этот вариант мне нравится больше, давайте реализовывать его.
Создаем класс EventsHelper:
Теперь обновляем наши патчи, чтобы они вызывали эти события:
GamePauseResumePatch делается аналогично. Пришлось добавить публичные методы FireOnGamePausedEvent и FireOnGameResumedEvent, так как нельзя вызывать события из-за пределов их класса. Теперь TimeTracker может в любой момент подписаться на события в EventsHelper. Получаем код со слабым зацеплением — именно из-за этого подход с событиями мне нравится больше, чем вариант с синглтоном или Resources.FindObjectsOfTypeAll().
Если мы соберем мод и запустим игру, то все будет работать. Однако, мы пока не учли одну деталь. В оригинальном коде GamePause.Pause() есть проверка от многократного перехода в режим паузы.
Postfix-патч же будет вызван в любом случае: и если мы установили паузу, и если это было повторное нажатие. А значит, и событие EventsHelper будет срабатывать всегда, даже если фактического перехода в паузу уже не было. Давайте добавим Prefix-патч, в котором будем проверять текущее состояние паузы. Harmony позволяет читать и изменять приватные переменные класса, а также передавать состояние между патчами одного метода. В Harmony вообще много чего можно получить в патче:
- Аргументы метода: собственно то, что было передано в метод при его вызове.
- __instance: ссылка на текущий объект, для которого вызван метод. По сути это просто this.
- __state: переменная любого типа для передачи состояния между патчами. Если нужно несколько переменных, то просто пишем структуру или класс.
- __result: возвращаемый результат оригинального метода. Если нужно, можно его изменить.
- Приватные переменные: добавляем три (3) знака подчеркивания (_) перед названием аргумента в патче, и Harmony подставит туда значение из приватной переменной.
Начнем со структуры, которая будет хранить состояние:
Нам нужно всего одно значение, чтобы отслеживать состояние паузы, поэтому структура избыточна, но как я уже писал выше, я люблю ясный код. PauseState __state — это более ясный код, чем просто bool __state.
Теперь добавляем Prefix-патч:
Здесь мы добавляем состояние с модификатором out, чтобы его можно было изменять, и приватную переменную ____pause (_pause и еще три подчеркивания перед ней). Просто сохраняем ____pause в __state — тут ничего хитрого.
Теперь обновляем Postfx-патч:
__state даст нам ту же структуру, которую мы записали в Prefix-патче. Сравниваем wasPaused с ____pause, чтобы проверить, что игра реально поставлена на паузу и вызываем событие.
Запускаем игру и проверяем, что все работает.
Вывод из шага 4: Harmony круто. Это очень полезная и важная для сообщества моддеров библиотека, которая используется в RimWorld, Battletech, Cities: Skylines, Kerbal Space Program, Oxygen Not Included, Stardew Valley, Subnautica и многих других играх.
Создание модов — это довольно утомительный процесс. Теперь вы знаете, что при разработке модов нужно постоянно копаться в декомпилированном коде, искать классы, которые делают то, что вам нужно, модифицировать их, постоянно пересобирать моды, чтобы проверить изменения в игре, страдать из-за отсутствия нормального режима отладки и полноценного Unity-редактора.
А потом разработчики выпускают новую версию игры, в которой они поменяли логику, которая использовалась в моде, и нужно делать все сначала.
Подписывайтесь на блог. Там я пишу про различные истории из геймдева, про технологии и про когнитивные искажения.