Bagaimana mod untuk game Unity dikembangkan. Bagian 2: menulis mod Anda

Pada bagian ini, dengan menggunakan contoh mod untuk Beat Saber, kita akan melihat prinsip-prinsip umum pengembangan mod untuk game Unity, mencari tahu kesulitan apa yang ada, dan juga berkenalan dengan Harmony, perpustakaan untuk memodifikasi kode game yang digunakan di RimWorld, Battletech, Cities: Skylines, dan banyak lagi permainan lainnya.


Meskipun artikel ini terlihat seperti tutorial tentang cara menulis mod Anda sendiri untuk Beat Saber, tujuannya adalah untuk menunjukkan prinsip-prinsip apa yang digunakan untuk membuat mod khusus dan masalah apa yang harus Anda selesaikan selama pengembangan. Segala sesuatu yang dijelaskan di sini, dengan beberapa syarat, berlaku untuk semua game Unity setidaknya di Windows.



Sumber Gambar: 1 , 2


Di seri sebelumnya


Bagian masa lalu


Informasi dari bagian pertama tidak diperlukan untuk memahami apa yang akan terjadi di sini, tetapi saya tetap menyarankan Anda untuk membacanya.


Berikut ini isinya (sangat) singkat:


( ) β€” 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 .





Membuat mod adalah proses yang agak membosankan. Saat mengembangkan mod, Anda harus terus-menerus mempelajari kode yang didekompilasi, mencari kelas yang melakukan apa yang Anda butuhkan, memodifikasinya, terus-menerus membangun kembali mod untuk memeriksa perubahan dalam game, menderita karena kurangnya mode debugging normal dan editor Unity yang lengkap.


Dan kemudian para pengembang merilis versi baru dari gim, di mana mereka mengubah logika yang digunakan dalam mod, dan Anda perlu melakukannya lagi.


All Articles