使用 BepInEx 和 HarmonyX 修改基于 Unity 的游戏——以《任务代号美人鱼:莲与深海的姐妹》为例

起因和目的

《任务代号美人鱼:莲与深海的姐妹》是一款相当有意思的游戏🤤在美美把玩一段时间后,我发现有些敌人的行为比较影响游戏体验,因此有些打算修改的地方。

BepInEx 框架提供了灵活修改 Unity 游戏内容的方法,可以很方便地实现需要的功能,还可以以扩展的形式即插即用,因此使用了 BepInEx 框架,以及针对游戏修改而构建的 HarmonyX 插件对游戏内容进行修改。

在这次修改中,我想要达成的目标包括:

  1. 去除飞行的无人机敌人的拘束能力(它们速度太快了,被抓住后手动脱出很浪费时间);
  2. 缩短肉壁触手破墙而出的反应时间(原本的反应速度太慢,破墙而出之后人早就走了);
  3. 为肉壁触手添加复活的能力。

创建项目前的准备工作

在创建项目前,还需要进行一点额外的工作:

  1. 获取游戏的 Unity 版本号;
  2. 使用合适的 BepInEx 插件初始化环境。

获取 Unity 版本号

获取游戏版本号的方法很简单,在 MissionMermaiden_Data/globalgamemanagers 文件中就有记录。

以文本文件的格式打开 globalgamemanagers 后,在前几行发现 2018.3.03f 的内容,说明 Unity 版本号是 2018.3.0

初始化 BepInEx

需要使用合适的架构初始化 BepInEx 插件,在 Windows 上,需要确认游戏是 x64 还是 x86 架构。

运行游戏后,在任务管理器中发现 MissionMermainden.exe(32 位),说明游戏基于 x86 架构。

BepInEx 的 GitHub Release 中下载对应架构的插件,解压到游戏文件夹中,运行一次游戏并退出,发现 BepInEx 文件夹中出现了额外的配置文件夹。

可以编辑配置文件 BepInEx/config/BepInEx.cfg 中的内容,开启控制台 logging 功能:

1
2
[Logging.Console]
Enabled = true # 开启了控制台输出

改变设置好后,可以在打开游戏后发现额外的日志输出窗口,便于调试代码。

创建项目

BepInEx 依赖 .NET SDK 进行开发,在 Windows 中,可以在 Visual Studio Installer 中直接安装 .NET 桌面开发 这一工作负荷来获取所需的开发组件。

在安装了工作负荷后,可以在游戏文件夹运行指令获取项目模板并创建项目:

1
2
3
4
5
6
7
8
# 获取项目模板
dotnet new -i BepInEx.Templates --nuget-source https://nuget.bepinex.dev/v3/index.json

# 限制 .NET SDK 版本,防止在 .NET 8.0 之后出现初始化错误
dotnet new globaljson --sdk-version 6.0.425

# 创建项目
dotnet new bepinex5plugin -n MissionMermaidenPlugin -U 2018.3.0

其中,MissionMermaidenPlugin 是项目的名称,2018.3.0 是之前获取的 Unity 版本号。

构建项目

创建项目后,可以在项目名称文件夹 MissionMermaidenPlugin 中找到初始化的项目代码,可以使用 Visual Studio 打开 CS 项目文件 MissionMermaidenPlugin.csproj 对项目进行编辑。

添加项目依赖

在项目文件夹中,添加对 HarmonyX 的依赖:

1
2
# 添加对 HarmonyX 的依赖
dotnet add package HarmonyX

导入游戏运行库依赖

在项目依赖项中添加游戏文件夹中的 MissionMermaiden_Data/Managed/Assembly-CSharp.dll 即可导入游戏本体中的内容。

在依赖条目中打开 Assembly-CSharp.dll,即可通过反编译浏览游戏本体中定义的各种类型和函数:

导入后的项目依赖结构 打开 DLL 后的内容

DLL 中没有注释,不过可以靠名字和代码内容推断,比如上面的 BindJellyManager 很明显是控制水母敌人的类型。

通过对代码进行浏览,发现:

  • VisorManager 是控制飞行无人机的类型;
  • FleshManager 是控制肉壁触手的类型。

接下来的代码编写将围绕这两个类型进行。

简单的测试

可以修改初始化后的代码,检查之前开启的控制台日志功能:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
namespace MissionMermaidenPlugin
{
[BepInPlugin(PluginInfo.PLUGIN_GUID, PluginInfo.PLUGIN_NAME, PluginInfo.PLUGIN_VERSION)]
public class Plugin : BaseUnityPlugin
{
// 添加了日志输出
static ManualLogSource globalLog = new ManualLogSource("globalLog");

private void Awake()
{
Logger.LogInfo($"Plugin {PluginInfo.PLUGIN_GUID} is loaded!");

// 添加了日志输出
HarmonyFileLog.Enabled = true;
BepInEx.Logging.Logger.Sources.Add(globalLog);
globalLog.LogInfo("Global log started.");
}
}
}

在编译项目后,将输出目录下的 MissionMermaidenPlugin.dllMissionMermaidenPlugin.pdb 文件移动到 BepInEx/Plugins/ 文件夹下,打开游戏,在日志窗口发现额外的日志输出,说明扩展成功地加载了。

编写代码

HarmonyX 提供了很多方便的方法,将用户定义的函数挂载到游戏的各个位置,可以在类型方法开始前或结束后触发额外的方法,改变类型的内容、改变方法的返回值,甚至可以覆盖原有的方法。

无人机

修改无人机敌人的行为可以提供一个简单的例子,因为使用的成员字段和方法都是公开的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
private class VisorTriggers
{
[HarmonyPrefix]
[HarmonyPatch(typeof(VisorManager), "OnTriggerEnter2D")]
public static void KeepAwayVisors(ref VisorManager __instance)
{
globalLog.LogInfo("Keep away visors!");

if (__instance.state == VisorManager.STATE.CHASE)
{
__instance.state = VisorManager.STATE.DEAD;
__instance.InstantDestroy();
}
}
}

为了方便管理,可以对每个敌人分别设置一个 Triggers 类型进行控制,上面的 VisorTriggers 就是针对无人机的。不要忘记在原本的 Awake 函数中添加应用 Trigger 的语句:

1
2
3
4
5
6
private void Awake()
{
// 之前的内容……

Harmony.CreateAndPatchAll(typeof(VisorTriggers)); // 应用 VisorTriggers 的内容到游戏中
}

在函数前的修饰包括:

  • [HarmonyPatch(typeof(VisorManager), "OnTriggerEnter2D")]:函数绑定到了 VisorManagerOnTriggerEnter2D 方法上;
  • [HarmonyPrefix]:函数在绑定的方法调用前进行。

函数的传入参数:

  • __instance 是对运行时 VisorManager 类变量的引用。
    • 既然获取了对变量的引用,就可以对其中的变量进行直接修改,比如 __instance.state = VisorManager.STATE.DEAD 就直接改变了敌人的状态。
    • 通过 __instance 获取对象引用的做法是 HarmonyX Patch 参数定义的,也可以按照上述定义传入其它参数,比如获取挂载到的方法的传入参数、劫持返回值等等。

函数的内容:

  • 挂载的方法 OnTriggerEnter2D 是分析反编译的文件后决定的。这个方法在无人机接触到物体时触发,当 state == VisorManager.STATE.CHASE 且和主角接触时,无人机将拘束主角;
  • 调用的 InstantDestroyVisorManager 类中一个独有的方法,可以直接将这个敌人摧毁(大概是因为速度太快很容易出 bug 吧)。

总之,这个额外的方法在无人机企图拘束主角之前直接将其摧毁,在我们和敌人玩耍时不再碍事😎

肉壁触手

对肉壁触手的修改相对来说更加复杂,因为涉及到了很多私有成员字段和方法,也需要对动画相关的逻辑做修改,以实现新的敌人行为(复活)。

缩短反应时间

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
private class FleshTriggers
{
enum FleshStates
{
HIDE,
IDLE,
DEAD
}

[HarmonyPrefix]
[HarmonyPatch(typeof(FleshManager), "OnTriggerEnter2D")]
public static bool FleshOnTriggerEnter2D(ref FleshManager __instance, Collider2D collision)
{
// 在这里访问了私有变量
var state = (FleshStates)AccessTools.Field(typeof(FleshManager), "state").GetValue(__instance);
var appearedCap = (bool)AccessTools.Field(typeof(FleshManager), "appearedCap").GetValue(__instance);
if (collision.gameObject.tag == "PlayerBody" && state == FleshStates.HIDE && !appearedCap)
{
float time = Random.Range(0.1f, 1f);
globalLog.LogInfo($"Changed appear time to {time}");
AccessTools.Field(typeof(FleshManager), "appearedCap").SetValue(__instance, true);
// 在这里调用了私有方法
__instance.Invoke("Appear", time);
return false;
}
return true;
}
}

上面的 Trigger 缩短了肉壁触手破墙的反应时间(从原游戏中的 0.5s–2s 缩短到 0.1s–1s)。

Patch 方法返回 true 将仍然调用原本的方法,让游戏按照原本的逻辑处理其他情况下的事件,而返回 false 则将跳过原本的方法(在 HarmonyX 文档中有相关定义)。

对私有成员的操作:

  • 读取私有变量:FleshManager.state 等变量是私有的,在读取时需要通过反射的方式获取;
    • FleshManager.state 是一个私有的枚举值变量,包括 HIDEIDLEDEAD 三种状态。使用反射访问时,既可以像上面一样转换成定义相同的枚举类型,也可以直接作为 int 读取。
  • 调用私有方法:FleshManager.Appear 是肉壁触手破墙的方法,也是私有的,需要使用 Invoke 调用。

延迟复活

首先是在敌人被杀死时添加复活的机制:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
[HarmonyPrefix]
[HarmonyPatch(typeof(FleshManager), "Dead")]
public static bool ReviveFlesh(ref FleshManager __instance)
{
var state = (FleshStates)AccessTools.Field(typeof(FleshManager), "state").GetValue(__instance);
var body = (GameObject)AccessTools.Field(typeof(FleshManager), "body").GetValue(__instance);

if (state == FleshStates.DEAD) return false;

// 在这里位为私有字段赋值
AccessTools.Field(typeof(FleshManager), "state").SetValue(__instance, (int)FleshStates.DEAD);

body.SetActive(value: false);
__instance.Invoke("PlayHitSE", 0);
__instance.hitParticle.Play();
__instance.slobberParticle.Stop();
__instance.animator.SetTrigger("GoDead");
__instance.gameManager.ChargeEnergy(3f);
__instance.Invoke("HasumiInactiveCheck", 0.1f);

// 在这里添加了延迟复活的机制
float time = Random.Range(1f, 2f);
globalLog.LogInfo($"Flesh will revive after {time}");
__instance.Invoke("Appear", time);

return false;
}

这里直接覆盖了原本的 Dead 方法(Patch 方法总返回 false,不再调用原本的 Dead 方法),在复制的原有的事件处理上,添加了随机 1s~2s 后复活的机制(Invoke 破墙的 Appear 方法)。

对私有成员的操作:

  • 写入成员字段:大部分字段可以直接使用反射的方法直接写入,但是 __instance.state 是一个私有的枚举值类型,需要将写入的状态强制转换成 int 再写入。

理论上来说,在上面的 Patch 中,一段时间后重新调用 Appear 应当可以实现复活的功能,不需要再对 Appear 进行修改。而在测试中(如下图),敌人被杀死之后虽然的确会复活(出现破墙时的 SE 和动画,靠近也会被拘束),但是外观仍然是被杀死后的样子(变暗并停止动作)。

经过分析后发现上述的问题和 UnityEngine.Animator 有关:Dead 方法中触发了 __instance.animatorGoDead 方法,改变敌人的外观;而 animator 没有恢复外观的相关内容。因此,如果不对 animator 做额外操作,敌人的外观将一直是死亡的状态。

因此,需要在复活时额外进行对 __instance.animator 的处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
[HarmonyPrefix]
[HarmonyPatch(typeof(FleshManager), "Appear")]
public static bool ResetAnimationAfterRevival(ref FleshManager __instance)
{
var state = (FleshStates)AccessTools.Field(typeof(FleshManager), "state").GetValue(__instance);

globalLog.LogInfo($"Appear");

if (state != FleshStates.DEAD) return true;

// 如果是复活触发的 Appear,在这里重置 Animator
globalLog.LogInfo($"ResetAnimationAfterRevival");
__instance.animator.Rebind();
__instance.animator.Update(0f);

return true;
}

这里在破墙方法 Appear 之前进行了 Patch,如果是复活触发的 Appear 方法,则重置 __instance.animator 的状态,将 animator 状态恢复到初始化时的状态。上述代码中的方法似乎是唯一能够完全重置外观的方法,使用 __instance.ResetTrigger("GoDead") 并不会奏效。

总结

由此,我们缩短了肉壁触手的破墙时间,并为其添加了被杀死后复活的行为。不要忘记在 Awake 中对游戏进行 Patch:

1
2
3
4
5
6
7
private void Awake()
{
// 之前的内容……

Harmony.CreateAndPatchAll(typeof(VisorTriggers)); // 应用 VisorTriggers 的内容到游戏中
Harmony.CreateAndPatchAll(typeof(FleshTriggers)); // 应用 FleshTriggers 的内容到游戏中
}

编译和测试

和之前的简单测试相同,构建插件并 MissionMermaidenPlugin.dllMissionMermaidenPlugin.pdb 文件移动到 BepInEx/Plugins/ 文件夹下,在游戏中进行测试。

现在在游戏中:

  1. 飞向主角企图进行拘束的无人机应当在接触到主角时直接消失;
  2. 隐藏的肉壁触手应当在主角路过后几乎立即破墙而出;
  3. 肉壁触手在被主角杀死之后,应当在短时间内复活。

好了,不多说了,我要去和触手肉壁玩耍了!