CĂłmo se desarrollan las modificaciones para los juegos de Unity. Parte 2: escribir tu mod

En esta parte, usando un ejemplo de un mod para Beat Saber, veremos los principios generales del desarrollo de mods para juegos de Unity, descubriremos qué dificultades existen y también nos familiarizaremos con Harmony, una biblioteca para modificar el código de juego utilizado en RimWorld, Battletech, Cities: Skylines y muchos otros juegos.


Aunque este artículo parece un tutorial sobre cómo escribir su propio mod para Beat Saber, su propósito es mostrar qué principios se utilizan para crear modificaciones personalizadas y qué problemas tiene que resolver durante el desarrollo. Todo lo descrito aquí, con algunas reservas, es aplicable a todos los juegos de Unity, al menos en Windows.



Fuentes de imagen: 1 , 2


En la serie anterior


Parte pasada


La información de la primera parte no es necesaria para comprender lo que sucederá aquí, pero aún así le aconsejo que la lea.


Aquí está su breve (muy) contenido:


( ) — 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 . .



, . 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 , 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 .





Crear mods es un proceso bastante tedioso. Al desarrollar mods, debe profundizar constantemente en el cĂłdigo descompilado, buscar clases que hagan lo que necesita, modificarlos, reconstruir constantemente los mods para verificar los cambios en el juego, sufrir la falta de un modo de depuraciĂłn normal y un editor Unity completo.


Y luego los desarrolladores lanzan una nueva versiĂłn del juego, en la que cambiaron la lĂłgica que se utilizĂł en el mod, y debes volver a hacerlo.


All Articles