1. 项目概述:为什么选择BepInEx作为Unity插件开发的起点?
如果你在Unity社区里混过一段时间,尤其是对《英灵神殿》、《腐蚀》这类由Unity开发的PC游戏进行过模组修改,那你大概率听说过BepInEx这个名字。它不是一个官方工具,但在玩家和模组开发者圈子里,它的地位几乎等同于“Unity插件开发的瑞士军刀”。今天,我们不谈那些复杂的底层原理,就从一个一线开发者的角度,聊聊怎么用BepInEx框架,在3小时内从“两眼一抹黑”到能做出一个能跑起来的、功能实用的Unity插件。这个时间不是噱头,而是基于一个清晰的、可复现的学习路径:1小时理解核心概念和搭建环境,1小时实现第一个“Hello World”级别的插件,再用1小时为它添加一个实用的游戏内功能。
为什么是BepInEx,而不是传统的Asset Bundle或者纯粹的反射(Reflection)?答案在于“侵入性”和“便捷性”。传统的模组开发要么需要反编译游戏程序集(风险高、难度大),要么依赖游戏官方提供的有限SDK。BepInEx采取了一种巧妙的“注入”方式。它本质上是一个“Unity引擎的插件加载器”,在游戏启动时,将自己“挂载”到Unity引擎和游戏代码之间。你可以把它想象成一个“中间人”或者“接线板”。游戏原本的代码(我们称之为“原版程序集”)照常运行,但BepInEx会在关键时刻,允许我们编写的插件代码“插入”到游戏原有的执行流程中,修改数据、调用函数,甚至创建全新的游戏对象。这种方式的优势非常明显:对游戏本体的修改极小(通常只是注入几个启动用的DLL文件),稳定性高,并且为开发者提供了一套相对标准化的API,大大降低了入门门槛。
对于想涉足Unity游戏功能扩展、自动化测试工具开发,甚至是特定垂直领域(如工业仿真、数字孪生)中快速定制功能的开发者来说,掌握BepInEx是一条高效的捷径。它让你能直接与运行时(Runtime)的游戏逻辑对话,这比从零开始做一个完整的Unity项目要快得多,也更有针对性。接下来,我们就抛开理论,直接上手。
2. 环境准备与核心工具链解析
工欲善其事,必先利其器。用BepInEx开发插件,你不需要一个完整的Unity Editor工程,但需要配置好一个“面向已编译游戏”的开发环境。这听起来有点绕,其实很简单:你的开发目标是修改一个已经打包好的、可执行的游戏(.exe)。因此,你的“开发环境”就是这个游戏本身,加上一套能让你编写和编译插件的工具。
2.1 目标游戏与BepInEx框架部署
首先,你需要一个目标。找一个你熟悉的、由Unity开发且支持BepInEx的PC游戏作为实验对象。社区支持度高的游戏如《Risk of Rain 2》、《Valheim》都是绝佳的选择,因为它们有庞大的模组生态,遇到问题容易找到解决方案。从Steam等平台安装好游戏后,第一步是部署BepInEx框架。
- 获取BepInEx:前往BepInEx的GitHub发布页,下载对应你游戏架构(通常是x64)的“BepInEx Unity IL2CPP”或“BepInEx Unity Mono”版本。如何判断?一个简单的方法是查看游戏根目录下是否有“UnityPlayer.dll”和一个同名的“GameAssembly.dll”(IL2CPP),或者只有“UnityPlayer.dll”和“<游戏名>_Data/Managed/Assembly-CSharp.dll”(Mono)。IL2CPP是Unity较新版本默认的脚本后端,性能更好,但插件开发原理相通。
- 部署:将下载的ZIP包解压,把里面的所有文件和文件夹直接复制到游戏的根目录(即.exe文件所在的目录)。通常,你会看到“BepInEx”、“doorstop_libs”、“winhttp.dll”等文件。
- 首次运行:启动一次游戏。如果部署成功,游戏会正常启动,并且在游戏根目录下会生成完整的“BepInEx”文件夹结构,其中
BepInEx/plugins文件夹就是未来存放你开发的插件的地方。首次运行后关闭游戏。
注意:务必使用与游戏版本匹配的BepInEx版本。如果游戏更新了,而BepInEx未跟进,可能会导致插件失效或游戏崩溃。在模组社区页面通常会有兼容性说明。
2.2 开发环境搭建:Visual Studio与必要组件
你的主要编码工具是Visual Studio(推荐2019或2022社区版)。除了安装基本的.NET桌面开发工作负载,还需要确保能引用到两个核心的程序集:
- 游戏程序集:这是你插件的“地图”。你需要引用游戏本身的代码库。对于Mono后端游戏,它们位于
<游戏根目录>/<游戏名>_Data/Managed/。关键文件是Assembly-CSharp.dll(包含大部分游戏逻辑),可能还有UnityEngine.dll、UnityEngine.CoreModule.dll等。对于IL2CPP后端,游戏逻辑被编译到了原生库GameAssembly.dll中,无法直接引用。这时你需要使用诸如dnSpy或Il2CppDumper这样的工具,将游戏的反编译C#代码导出为一个可引用的“伪”程序集,或者更常见的做法是,直接引用BepInEx自带的、针对IL2CPP做了适配的UnityEngine和Assembly-CSharp的“存根”DLL(这些DLL通常由社区提供,只包含类型定义,不包含实现,用于编译时通过类型检查)。 - BepInEx核心库:在部署好的
BepInEx/core文件夹下,找到BepInEx.Core.dll和0Harmony.dll(或HarmonyX)。这两个是编写插件必须引用的。BepInEx.Core.dll提供了插件基类、配置系统和日志工具;0Harmony.dll则是实现代码“注入”(Patch)的利器,是BepInEx能力的核心。
在Visual Studio中新建一个“类库(.NET Framework 或 .NET Standard)”项目,目标框架版本建议选择.NET 3.5或.NET Standard 2.0,以兼容大多数游戏环境。然后将上述两个核心程序集添加为项目引用。
2.3 辅助工具:dnSpy与调试技巧
dnSpy不仅仅是一个反编译工具,更是BepInEx插件开发者的“眼睛”。你可以用它直接打开游戏的主程序集(如Assembly-CSharp.dll),浏览所有的类、方法、字段,查看游戏的具体实现逻辑。当你需要知道“玩家的生命值存在哪个类的哪个变量里”或者“哪个方法负责生成怪物”时,dnSpy是你的第一选择。
关于调试,由于插件是加载到游戏进程中的,你可以使用Visual Studio的“附加到进程”功能进行调试。首先,在VS中为你的插件项目设置生成后事件,将编译好的插件DLL自动复制到游戏的BepInEx/plugins文件夹下。然后,在代码中设置断点,启动游戏,再在VS中选择“调试”->“附加到进程”,找到游戏的进程并附加。这样,当游戏执行到你的插件代码时,就会触发断点。这是一个非常强大的功能,能让你实时观察和修改变量。
3. 第一个插件:从“Hello World”到游戏内日志输出
理论说再多不如动手做一遍。我们的第一个目标不是改变游戏,而是证明我们的插件能被成功加载并运行。
3.1 创建插件基类与元数据
在Visual Studio项目中,创建一个新的C#类,例如MyFirstPlugin.cs。让它继承自BaseUnityPlugin,这是BepInEx所有插件的基类。
using BepInEx; using BepInEx.Logging; using UnityEngine; namespace MyFirstBepInExPlugin { // 最重要的特性:BepInPlugin // GUID必须是全球唯一的,通常使用“作者名.插件名”的格式 // 插件名称和版本号会显示在BepInEx的插件管理界面 [BepInPlugin(PluginGUID, PluginName, PluginVersion)] public class MyFirstPlugin : BaseUnityPlugin { public const string PluginGUID = "com.yourname.myfirstplugin"; public const string PluginName = "My First BepInEx Plugin"; public const string PluginVersion = "1.0.0"; // 内部日志记录器,用于输出信息到BepInEx的日志文件和控制台 internal static ManualLogSource Log; // Awake方法是插件的入口点,在插件被加载时由BepInEx自动调用 private void Awake() { // 初始化日志记录器,Logger是基类BaseUnityPlugin提供的属性 Log = Logger; // 我们的第一行代码:在BepInEx的日志中输出信息 Log.LogInfo($"插件 {PluginName} 已加载!"); // 尝试在游戏内也输出信息(这需要用到Unity的API) // 注意:此时Unity引擎可能尚未完全初始化,某些API可能不可用 // 更安全的做法是在Start或Update等生命周期方法中调用 } } }编译这个项目,将生成的DLL文件(例如MyFirstBepInExPlugin.dll)放入游戏的BepInEx/plugins文件夹。启动游戏,如果一切正常,你会在游戏根目录的BepInEx/LogOutput.log文件中看到一行:“[Info : My First BepInEx Plugin] 插件 My First BepInEx Plugin 已加载!”。恭喜,你的第一个插件已经成功运行了!
3.2 与游戏世界交互:在屏幕上显示文字
仅在日志中输出信息还不够直观。让我们更进一步,在游戏的画面上直接显示文字。这需要用到Unity的OnGUI方法,它是Unity旧版UI系统(IMGUI)的渲染入口,虽然效率不高,但用于调试和显示简单信息极其方便。
我们在插件类中添加一个OnGUI方法:
private void OnGUI() { // 创建一个在屏幕左上角显示的标签 GUI.Label(new Rect(10, 10, 400, 30), $"我的插件正在运行!时间:{Time.time:F2}"); }OnGUI方法会在每一帧被Unity调用。GUI.Label用于绘制一个文本标签。new Rect(10, 10, 400, 30)定义了标签的位置和大小(距离屏幕左边缘10像素,上边缘10像素,宽400像素,高30像素)。Time.time是Unity提供的自游戏开始以来的时间(秒),:F2格式化为保留两位小数。
重新编译并替换DLL,启动游戏。你应该能在屏幕左上角看到不断更新的时间文字。这说明你的插件已经能够访问Unity引擎的核心API并影响游戏渲染了。这是一个重要的里程碑。
实操心得:
OnGUI非常耗性能,只适合用于调试信息显示或极其简单的UI。对于正式的插件UI,建议使用Unity的UGUI或第三方UI框架,并通过BepInEx的配置管理器(ConfigEntry)来绑定设置,但这需要更复杂的资源加载和管理,超出了3小时入门范围。第一步,先确保功能能跑通。
4. 核心技能:使用Harmony进行代码注入(Patching)
插件加载和显示UI只是“存在”,真正的力量在于“修改”。这就是Harmony库大显身手的地方。Harmony允许你在不修改原始游戏DLL文件的情况下,在运行时修改游戏代码的行为。它主要有三种补丁(Patch)方式:前缀(Prefix)、后缀(Postfix)和中缀(Transpiler)。对于入门,我们重点掌握最常用的前两种。
4.1 理解Harmony补丁的工作原理
想象一下游戏里有一个方法叫Player.TakeDamage(int amount)。我们想在玩家每次受到伤害时,先打印一条日志,再把伤害值减半。
- 前缀(Prefix):在原方法执行之前运行。它可以访问原方法的参数,并可以修改它们,甚至可以决定是否跳过原方法的执行。
- 后缀(Postfix):在原方法执行之后运行。它可以访问原方法的参数、返回值以及一个名为
__instance的特殊参数(代表调用该方法的原对象实例)。
我们的逻辑是:先打印日志(前缀或后缀都可以),然后修改伤害值。修改传入参数最适合在前缀中做。
4.2 实战:修改玩家伤害值
假设通过dnSpy分析,我们找到了目标游戏中的玩家类Player和其受伤方法ApplyDamage。下面演示如何实现伤害减半。
首先,在插件项目中安装Harmony库。可以通过NuGet包管理器搜索“Lib.Harmony”或“HarmonyX”来安装。然后在插件类中创建Harmony实例并应用补丁。
using HarmonyLib; // 引入Harmony命名空间 namespace MyFirstBepInExPlugin { [BepInPlugin(PluginGUID, PluginName, PluginVersion)] public class MyFirstPlugin : BaseUnityPlugin { // ... 之前的GUID、名称等定义 ... // 声明一个Harmony实例 private static Harmony _harmony; private void Awake() { Log = Logger; Log.LogInfo($"插件 {PluginName} 已加载!"); // 初始化Harmony实例,传入插件的GUID _harmony = new Harmony(PluginGUID); // 应用所有补丁 try { _harmony.PatchAll(); // 这会自动搜索当前程序集中所有带有[HarmonyPatch]特性的类 Log.LogInfo("Harmony补丁应用成功!"); } catch (System.Exception ex) { Log.LogError($"应用Harmony补丁时出错:{ex}"); } } // 当插件被卸载时(游戏关闭),清理Harmony补丁 private void OnDestroy() { _harmony?.UnpatchSelf(); Log.LogInfo("插件已卸载,Harmony补丁已清理。"); } } // 定义一个专门的类来存放我们的补丁 [HarmonyPatch] // 告知Harmony这是一个补丁类 public static class PlayerDamagePatch { // 指定要修补的目标方法:Player类的ApplyDamage方法,接受一个int参数 [HarmonyPatch(typeof(Player), nameof(Player.ApplyDamage))] [HarmonyPrefix] // 声明这是一个前缀补丁 public static bool Prefix_ApplyDamage(ref int damage) { // 获取当前插件的日志实例(需要想办法访问,这里是一种简单方式) var logger = MyFirstPlugin.Log; if (logger != null) { logger.LogInfo($"玩家即将受到伤害:{damage}"); } // 将伤害值减半 damage = damage / 2; if (logger != null) { logger.LogInfo($"伤害已减半,实际伤害:{damage}"); } // 返回true,表示继续执行原方法;返回false则会跳过原方法 return true; } } }代码解析:
[HarmonyPatch(typeof(Player), nameof(Player.ApplyDamage))]:这个特性告诉Harmony,这个补丁是针对Player类的ApplyDamage方法。你需要通过dnSpy确认准确的类名和方法名。[HarmonyPrefix]:表明下面的静态方法是前缀补丁。public static bool Prefix_ApplyDamage(ref int damage):补丁方法必须是static。参数ref int damage对应原方法的int类型参数。使用ref关键字,意味着我们可以修改这个传入值。返回值是bool,返回true让原方法继续执行,返回false则阻止原方法执行。- 在方法内部,我们通过
MyFirstPlugin.Log(之前定义的静态变量)输出日志,然后直接修改了damage的值。
编译、部署、运行游戏。当你控制的角色受到伤害时,查看LogOutput.log文件,你应该能看到类似“玩家即将受到伤害:10”和“伤害已减半,实际伤害:5”的日志,并且在游戏中,角色实际受到的伤害也会减半。
4.3 访问实例成员与后缀补丁应用
有时我们需要在方法执行后,根据结果做一些操作,或者读取/修改调用该方法的对象实例(Player)的字段。这时就需要用到后缀补丁和__instance参数。
假设我们想在玩家每次攻击后,在屏幕上显示一条攻击信息。原方法可能是Player.PerformAttack()。
[HarmonyPatch] public static class PlayerAttackPatch { [HarmonyPatch(typeof(Player), nameof(Player.PerformAttack))] [HarmonyPostfix] // 声明为后缀补丁 public static void Postfix_PerformAttack(Player __instance) { // __instance 就是调用PerformAttack的那个Player对象 string playerName = __instance.characterName; // 假设有个字段叫characterName MyFirstPlugin.Log?.LogInfo($"{playerName} 发动了一次攻击!"); // 我们也可以在这里调用插件类的其他方法,例如更新UI // 注意:Unity的UI操作需要在主线程,而游戏逻辑方法通常就在主线程中执行,所以这里直接调用OnGUI相关的逻辑是安全的。 } }代码解析:
[HarmonyPostfix]:声明为后缀补丁。public static void Postfix_PerformAttack(Player __instance):后缀补丁方法可以返回void。参数Player __instance是Harmony提供的特殊参数,它自动传递了调用该方法的对象实例。通过它,我们可以访问和修改这个玩家对象的所有公共或私有字段(配合Harmony的Traverse工具或反射)。- 在方法内部,我们记录了日志。你完全可以在这里添加更复杂的逻辑,比如给玩家添加一个增益效果,或者触发一个自定义事件。
注意事项:使用Harmony时,目标方法的签名(参数类型、返回类型)必须完全匹配。通过
dnSpy查看时,要特别注意参数是int还是float,是否有ref或out修饰符。签名不匹配会导致补丁应用失败。另外,修补私有(private)或受保护(protected)方法也是可以的,Harmony能处理。
5. 构建实用功能:创建一个简单的游戏内菜单
现在我们已经掌握了加载、显示、修改游戏逻辑的能力。让我们综合运用这些知识,构建一个稍微复杂点的实用功能:一个简单的游戏内菜单,可以让我们按一个键(比如F1)来打开/关闭一个面板,并在面板上实现一些功能按钮,比如“无敌模式”、“一击必杀”。
5.1 状态管理与UI绘制
我们需要管理菜单的开关状态,并在OnGUI中根据状态绘制不同的内容。修改我们的插件主类:
public class MyFirstPlugin : BaseUnityPlugin { // ... 之前的常量、日志、Harmony实例声明 ... // 新增:菜单是否显示的标志位 private bool _isMenuVisible = false; private void Awake() { // ... 之前的初始化代码 ... } private void Update() { // 在Update中检测按键输入 // Unity的Input.GetKeyDown会在按键按下的那一帧返回true if (Input.GetKeyDown(KeyCode.F1)) { _isMenuVisible = !_isMenuVisible; // 切换菜单显示状态 Log.LogInfo($"菜单显示状态切换为:{_isMenuVisible}"); } } private void OnGUI() { // 始终显示的基础信息(可选) GUI.Label(new Rect(10, 10, 400, 30), $"插件运行中 (F1开关菜单)"); // 如果菜单不显示,则直接返回 if (!_isMenuVisible) return; // 绘制一个半透明的背景框 GUI.Box(new Rect(Screen.width - 320, 50, 300, 250), "插件功能菜单"); // 在框内开始布局,使用GUI.Window或者简单的垂直布局 GUILayout.BeginArea(new Rect(Screen.width - 310, 80, 280, 210)); // 显示一些状态信息 GUILayout.Label($"游戏时间:{Time.time:F1}s"); GUILayout.Label($"菜单状态:{_isMenuVisible}"); GUILayout.Space(10); // 空行 // 功能按钮 if (GUILayout.Button("无敌模式 (开关)")) { ToggleGodMode(); } if (GUILayout.Button("一击必杀 (开关)")) { ToggleOneHitKill(); } if (GUILayout.Button("获取100金币")) { AddMoney(100); } if (GUILayout.Button("关闭菜单")) { _isMenuVisible = false; } GUILayout.EndArea(); } // 下面需要实现这些功能方法... private void ToggleGodMode() { /* 通过Harmony补丁或反射修改玩家生命值逻辑 */ } private void ToggleOneHitKill() { /* 修改伤害计算逻辑 */ } private void AddMoney(int amount) { /* 找到玩家的金钱字段并增加 */ } }这段代码创建了一个通过F1键触发的简单开关菜单。菜单绘制在屏幕右上角,包含几个功能按钮。Update方法用于每帧检测输入,OnGUI负责渲染。
5.2 实现菜单功能:与游戏数据交互
按钮的逻辑需要真正地修改游戏状态。这通常需要通过Harmony补丁或C#反射(Reflection)来实现。这里以“获取100金币”为例,演示如何使用反射(因为更直接,适合一次性操作)。
假设我们知道玩家的金钱存储在Player类的currentMoney这个私有整型字段中。
using System.Reflection; // 需要引入反射命名空间 private void AddMoney(int amount) { try { // 1. 找到当前的玩家对象。这通常需要通过游戏的管理器类来获取。 // 假设游戏有一个单例类`GameManager`,其中有一个`LocalPlayer`属性。 // 你需要用dnSpy找到正确的获取方式。 // 这里是一个示例路径: // Player localPlayer = GameManager.Instance?.LocalPlayer; // 为了演示,我们假设我们已经有了一个playerInstance object playerInstance = GetLocalPlayer(); // 这是一个你需要自己实现的方法 if (playerInstance == null) { Log.LogWarning("无法找到本地玩家实例!"); return; } // 2. 使用反射获取`currentMoney`字段 Type playerType = playerInstance.GetType(); // BindingFlags.NonPublic | BindingFlags.Instance 表示查找非公共的实例字段 FieldInfo moneyField = playerType.GetField("currentMoney", BindingFlags.NonPublic | BindingFlags.Instance); if (moneyField == null) { // 如果字段名不对,可能是属性。尝试查找属性。 PropertyInfo moneyProperty = playerType.GetProperty("CurrentMoney", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Public); if (moneyProperty != null && moneyProperty.CanRead && moneyProperty.CanWrite) { int current = (int)moneyProperty.GetValue(playerInstance); moneyProperty.SetValue(playerInstance, current + amount); Log.LogInfo($"通过属性增加金钱成功!当前金钱:{current + amount}"); } else { Log.LogError("未找到玩家的金钱字段或属性!"); } return; } // 3. 读取当前值,增加,并写回 int currentMoney = (int)moneyField.GetValue(playerInstance); int newMoney = currentMoney + amount; moneyField.SetValue(playerInstance, newMoney); Log.LogInfo($"增加金钱成功!当前金钱:{newMoney}"); } catch (System.Exception ex) { Log.LogError($"增加金钱时发生错误:{ex}"); } } // 一个示例方法,你需要根据实际游戏代码填充其实现 private object GetLocalPlayer() { // 使用反射或Harmony的Traverse来安全地访问游戏内的单例或管理器。 // 例如: // Type gameManagerType = Type.GetType("GameManager, Assembly-CSharp"); // PropertyInfo instanceProp = gameManagerType?.GetProperty("Instance", BindingFlags.Public | BindingFlags.Static); // object gameManager = instanceProp?.GetValue(null); // PropertyInfo playerProp = gameManager?.GetType().GetProperty("LocalPlayer"); // return playerProp?.GetValue(gameManager); return null; // 暂时返回null,需要你根据游戏实际情况实现 }对于“无敌模式”和“一击必杀”,更优雅和高效的做法是使用Harmony补丁。例如,“无敌模式”可以修补Player.ApplyDamage方法,在前缀中直接将伤害设置为0并返回false(跳过原方法)。“一击必杀”可以修补玩家或武器计算伤害的方法,将最终伤害值设为一个极大的数。
5.3 配置的持久化:使用BepInEx配置文件
一个专业的插件应该允许用户配置快捷键、开关功能等。BepInEx内置了配置系统。我们可以轻松地将菜单的开关键从固定的F1改为可配置的。
在插件类的Awake方法中,添加配置绑定:
private void Awake() { Log = Logger; // 绑定配置项 // 参数:配置分组(可为空)、配置项键名、默认值、配置描述 ConfigEntry<KeyboardShortcut> toggleMenuShortcut = Config.Bind( "Hotkeys", // 分组名 "ToggleMenu", // 键名 new KeyboardShortcut(KeyCode.F1), // 默认值:F1键 "按下此快捷键切换菜单显示/隐藏" // 描述 ); // 在Update中使用配置的快捷键 // 注意:需要保存对配置对象的引用,以便在Update中访问 _toggleMenuShortcut = toggleMenuShortcut; // ... 其他初始化代码 ... } // 在类中声明一个字段来保存配置引用 private ConfigEntry<KeyboardShortcut> _toggleMenuShortcut; private void Update() { // 使用配置的快捷键,而不是硬编码的KeyCode.F1 if (_toggleMenuShortcut.Value.IsDown()) { _isMenuVisible = !_isMenuVisible; } }这样,用户就可以在游戏目录下的BepInEx/config文件夹中,找到以你的插件GUID命名的.cfg文件(如com.yourname.myfirstplugin.cfg),并手动修改快捷键了。更高级的插件还会提供游戏内的配置界面。
6. 调试、打包与发布指南
开发过程中难免遇到问题,插件写好了也需要分享给别人。
6.1 常见问题与排查技巧实录
插件未加载:
- 检查日志:首先查看
BepInEx/LogOutput.log。如果插件完全没被加载,日志里可能都没有你插件的名字。检查DLL是否放对了位置(BepInEx/plugins或其子文件夹),文件名是否正确。 - 检查依赖:你的插件DLL可能依赖其他库(如特定的Harmony版本)。确保所有依赖项都放在了
BepInEx/plugins文件夹下,或者使用BepInEx的BepInDependency特性声明依赖。 - 检查游戏日志:有时游戏崩溃会生成独立的错误日志,查看Windows事件查看器或游戏目录下的
output_log.txt(旧版Unity)可能找到线索。
- 检查日志:首先查看
Harmony补丁应用失败:
- 查看BepInEx日志:应用补丁时的异常会打印在日志中。常见原因是目标方法签名不匹配、方法不存在(游戏版本更新)、或补丁类本身有编译错误。
- 使用Harmony的Debug模式:在
Awake中创建Harmony实例时,可以传入一个日志记录函数,或者查看Harmony生成的报告文件(如果启用),里面会详细列出所有找到的方法和应用的补丁。 - 逐步缩小范围:先做一个最简单的、什么都不做的后缀补丁(只打印日志)来测试目标方法是否正确。确认方法无误后再添加复杂逻辑。
游戏崩溃或行为异常:
- 检查空引用(NullReferenceException):这是Unity开发中最常见的错误。确保你通过反射或
__instance获取的对象不是null。在使用前进行判空。 - 线程安全:确保你的插件代码只在Unity的主线程中执行。从Harmony补丁内、
Update、OnGUI中调用通常是安全的。避免在异步回调或新线程中直接操作Unity对象。 - 补丁冲突:如果你的插件和其他模组修改了同一个方法,可能会冲突。尝试调整补丁的执行顺序(使用Harmony的
Priority特性),或者检查是否为同一个功能提供了多个补丁。
- 检查空引用(NullReferenceException):这是Unity开发中最常见的错误。确保你通过反射或
反射找不到字段/方法:
- 确认名称和类型:使用
dnSpy仔细核对字段或方法的全名(包括命名空间)、访问修饰符(public/private)、是否是静态(static)。 - 使用正确的BindingFlags:查找私有实例字段用
BindingFlags.NonPublic | BindingFlags.Instance;查找公共静态属性用BindingFlags.Public | BindingFlags.Static。 - 考虑继承链:如果要查找的成员在基类中,可能需要使用
Type.GetField的重载版本指定BindingFlags.FlattenHierarchy,或者遍历基类。
- 确认名称和类型:使用
6.2 插件打包与发布
当你完成插件开发并测试稳定后,可以打包分享。
最小发布包:通常包括以下文件:
- 你的插件主DLL(例如
MyAwesomeMod.dll)。 - 依赖的第三方库DLL(如果不是BepInEx自带的)。
- 一个
README.md或manifest.json文件(如果你发布到Thunderstore等模组平台,需要按照平台规范编写manifest文件,包含名称、版本、作者、描述、依赖项等)。 - 可选的图标和配置文件。
- 你的插件主DLL(例如
版本管理:务必更新插件类上的
[BepInPlugin]特性中的版本号。这有助于用户和管理器识别更新。依赖声明:如果你的插件强依赖另一个BepInEx插件,使用
[BepInDependency(“其他插件的GUID”, BepInDependency.DependencyFlags.HardDependency)]特性来声明。这样BepInEx会确保依赖插件先被加载。发布平台:对于Unity游戏模组,常见的发布平台有:
- Thunderstore:很多流行游戏(如Valheim, Risk of Rain 2)的官方模组平台,有方便的安装器(r2modman, Thunderstore Mod Manager)。
- GitHub Releases:适合技术用户,可以托管文件并提供更新日志。
- Nexus Mods:老牌模组网站,社区庞大。
编写说明文档:在
README中清晰说明插件的功能、安装方法(将DLL放入BepInEx/plugins)、配置方法、已知问题等。好的文档能减少大量用户支持工作。
走到这一步,你已经完成了一个功能完整、具备配置能力、可发布的BepInEx插件。从环境搭建到代码注入,再到UI交互和问题排查,这3小时的旅程覆盖了Unity插件开发的核心闭环。记住,探索的过程就是不断使用dnSpy阅读游戏代码、用Harmony尝试注入、观察日志、调试和迭代的过程。每个游戏都是一座待挖掘的金矿,而BepInEx和Harmony就是你手中的矿镐和地图。