Unity游戏的mod是如何开发的。第2部分:编写您的mod

在这一部分中,我们将以Beat Saber的mod为例,研究为Unity游戏开发mod的一般原理,找出存在的困难,并熟悉Harmony,该Harmony是一个用于修改RimWorld,Battletech,Cities:Skylines和许多游戏代码的库其他游戏。


尽管本文看起来像是一个有关如何为Beat Saber编写自己的mod的教程,但其目的是展示用于创建任何自定义mod的原理以及在开发过程中必须解决的问题。保留所有权利,此处描述的所有内容至少在Windows上适用于所有Unity游戏。



图像源:12


在上一个系列中


过去的部分


不需要第一部分的信息就可以了解这里会发生什么,但是我仍然建议您阅读它。


这是其简短(非常)的内容:


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





创建mod是一个相当繁琐的过程。在开发mod时,您需要不断地研究反编译的代码,寻找可以满足您需求的类,对其进行修改,不断重建mod以检查游戏中的变化,并且缺乏常规的调试模式和功能强大的Unity编辑器。


然后,开发人员发布游戏的新版本,在其中他们更改了Mod中使用的逻辑,您需要重新做一遍。


All Articles