跳转至

跨 Mod 交互

有时我们会希望使用其他 Mod 或提供自己 Mod 的功能, 这一节将介绍 ModInterop 的使用及一些常见的交互方法.

依赖管理

在与其他 Mod 交互前, 我们需要在 everest.yaml 中添加相应的依赖. 这里我们以 GravityHelper 为例:

everest.yaml
1
2
3
4
5
6
7
8
9
- Name: MyCelesteMod
  Version: 0.1.0
  DLL: MyCelesteMod.dll
  Dependencies:
    - Name: Everest
      Version: 1.4000.0
  OptionalDependencies:
    - Name: GravityHelper
      Version: 1.2.20

everest.yaml 中的依赖分为以下两种:

  • Dependencies 必需依赖: 必须在你的 Mod 加载前完成加载.
  • OptionalDependencies 可选依赖: 只有在被启用时加载, 未启用则会忽略.

通常为了保持 Mod 的轻量性与灵活性, 建议尽可能减少必需依赖的数量. 如果一个依赖是可选的, 我们应该在使用被依赖的功能前检查它是否被成功加载.

一个可能的实现如下:

MyCelesteModModule.cs
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
public static bool GravityHelperLoaded;

public override void Load()
{
    // 获取 GravityHelperModule 的元数据
    EverestModuleMetadata gravityHelperMetadata = new()
    {
        Name = "GravityHelper",
        Version = new Version(1, 2, 20)
    };

    // 判断 GravityHelper 是否成功加载
    GravityHelperLoaded = Everest.Loader.DependencyLoaded(gravityHelperMetadata);
}

这样我们就能在使用被依赖的功能前检查 GravityHelperLoaded 以确保被依赖的 Mod 成功加载.

ModInterop

ModInteropMonoMod 的一项十分强大的功能, 其提供了一种标准化的方式以实现不同 Mod 间的交互, 几乎可以视为我们拥有的最接近 "官方" 的 API.

一个 Mod 可以通过 ModExportName 特性导出一组方法, 而其他的 Mod 可以通过 ModImportName 特性导入这些方法作为委托以调用.

Info

如果被依赖的 Mod 被禁用, 其通过 ModExportName 导出的委托将会是 null, 调用它们将导致游戏崩溃. 我们应该检查被依赖的 Mod 是否启用以决定是否调用.

下面我们将介绍这两种特性的使用.

ModExportNameAttribute

ModExportNameAttribute(string name) 特性用于导出我们希望提供的方法, name 参数是我们提供的一系列 API 的唯一标识符, 用于在其他 Mod 中引用此 API.

下面我们新建一个 MyCelesteModExports 类进行示例:

MyCelesteModExports.cs
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
using MonoMod.ModInterop;

// 定义我们希望导出的 ModInterop API
[ModExportName("MyCelesteMod")]
public static class MyCelesteModExports
{
    // 添加于 2.0.2 版本
    public static int GetNumber() => 202;
    // 添加于 1.0.1 版本
    public static int MultiplyByTwo(int num) => num * 2;
    // 添加于 1.0.0 版本
    public static void LogStuff() => Logger.Log(LogLevel.Info, "MyCelesteMod", "Someone is calling this method!");
}

Info

请记住, API 是一种契约. 使用你的 API 的 Mod 作者会期望它至少能在你的 API 下一个主要版本前保持稳定. 因此, 我们强烈建议你记录每个版本 API 的修改, 至少包括每个方法的添加版本. 这样可以帮助其他 Mod 作者了解哪些 API 是新增的, 哪些是更改过的, 尽可能避免因接口变动而导致的问题.

在完成了上面这些后, 我们还需要在 Mod 的 Module 中注册导出的 ModInterop API:

MyCelesteModModule.cs
1
2
3
4
5
6
7
using MonoMod.ModInterop;

public override void Load()
{
    // 注册 ModInterop API
    typeof(MyCelesteModExports).ModInterop();
}

这样, 我们就完成 ModInterop API 的导出.

ModImportNameAttribute

ModImportNameAttribute(string name) 特性用于在你的 Mod 中引入其他 Mod 导出的 API, name 参数是我们导入 API 的唯一标识符, 用于指定我们需要导入的 API.

下面我们新建另一个 Mod AnotherCelesteMod 导入 MyCelesteMod 提供的 API:

Info

在导入前记得在 everest.yaml 中添加 MyCelesteMod 的可选依赖.

MyCelesteModAPI.cs
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
using MonoMod.ModInterop;

// 导入我们希望使用的 ModInterop API
// ModImportName 的参数必须与对应的 ModExportName 的参数匹配
[ModImportName("MyCelesteMod")]
public static class MyCelesteModAPI
{
    public static Func<int> GetNumber;
    public static Func<int, int> MultiplyByTwo;
    public static Action LogStuff;
}

别忘了在 Module 中注册我们导入的 API:

AnotherCelesteModModule.cs
1
2
3
4
5
6
7
using MonoMod.ModInterop;

public override void Load()
{
    // 注册 ModInterop API
    typeof(MyCelesteModAPI).ModInterop();
}

现在我们可以通过 MyCelesteModAPI 中导入的委托以调用我们希望使用的功能:

1
2
3
4
5
6
int myNumber = MyCelesteModAPI.GetNumber();

if (MyCelesteModAPI.MultiplyByTwo(myNumber) > 400)
{
    MyCelesteModAPI.LogStuff();
}

通过这种方式, 我们可以在自己的 Mod 中访问并调用其他 Mod 提供的功能, 而不需要直接依赖该 Mod 的程序集.

直接程序集引用

有时候我们需要直接使用目标Mod中的类型和方法, 但目标 Mod 并没有实现 ModInterop API. 这种情况下, 我们可以直接引用其他 Mod 的程序集.

下面我们介绍两种方法:

Cache

Everest 会将所有 Code Mod 的程序集使用 MonoMod 进行 patch 处理后放置到 Celeste/Mods/Cache/<mod名>.<程序集名>.dll 中.
我们可以通过配置模板的 .csporj 文件以直接引用它们:

MyCelesteMod.csproj
 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
<Project Sdk="Microsoft.NET.Sdk">
  <Import Project="CelesteMod.props" />

  <PropertyGroup>
    <RootNamespace>Celeste.Mod.MyCelesteMod</RootNamespace>
    <LangVersion>latest</LangVersion>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>

  <ItemGroup>
    <None Include="CelesteMod.props">
      <Visible>false</Visible>
    </None>
    <None Include="CelesteMod.targets">
      <Visible>false</Visible>
    </None>
  </ItemGroup>

<ItemGroup>
    <CelesteModReference Include="GravityHelper" />
    <CelesteModReference Include="ExtendedVariantMode" />
    <CelesteModReference Include="FrostHelper" AssemblyName="FrostTempleHelper" />
</ItemGroup>

  <Import Project="CelesteMod.targets" />
</Project>

Info

在引用之前我们需要确认目标 Mod 在 Cache 中的是否存在, 以上面引用的 Mod 为例. Cache 中应该存在:

  • GravityHelper.GravityHelper.dll
  • ExtendedVariantMode.ExtendedVariantMode.dll

我们填写目标 Mod 在 Cache 中名称的前半段就行.

lib-stripped

lib-stripped 是指剥离了所有方法实现的程序集, 仅保留类型和方法签名.
我们可以通过 NStripBepInEx.AssemblyPublicizerstrip-only 模式等工具对目标程序集进行剥离.
完成后我们可以直接引用被剥离的程序集.

通过 EverestModule 反射获取程序集

我们也可以通过 EverestModule 反射动态地访问我们希望交互的 Mod 的程序集, 而无需直接引用目标 Mod 的程序集.

下面我们以 GravityHelper 为例:

MyCelesteModModule.cs
 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
29
30
31
32
33
34
public static bool GravityHelperLoaded;

public static PropertyInfo PlayerGravityComponentProperty;
public static PropertyInfo IsPlayerInvertedProperty;
public static MethodInfo SetPlayerGravityMethod;

public override void Load()
{
    // 获取 GravityHelperModule 的元数据
    EverestModuleMetadata gravityHelperMetadata = new()
    {
        Name = "GravityHelper",
        Version = new Version(1, 2, 20)
    };

    GravityHelperLoaded = Everest.Loader.DependencyLoaded(gravityHelper);

    // 通过 EverestModule 反射获取 GravityHelper 的程序集
    if (Everest.Loader.TryGetDependency(gravityHelperMetadata, out var gravityModule))
    {
        // 反射获取 Celeste.Mod.GravityHelper.GravityHelperModule 类型
        Assembly gravityAssembly = gravityModule.GetType().Assembly;
        Type gravityHelperModuleType = gravityAssembly.GetType("Celeste.Mod.GravityHelper.GravityHelperModule");

        // 反射获取 GravityHelper.GravityHelperModule.PlayerComponent 属性
        PlayerGravityComponentProperty = gravityHelperModuleType?.GetProperty("PlayerComponent", BindingFlags.NonPublic | BindingFlags.Static);

        // 反射获取 GravityHelper.Components.SetPlayerGravity 方法
        SetPlayerGravityMethod = PlayerGravityComponentProperty?.GetValue(null)?.GetType().GetMethod("SetGravity", BindingFlags.Public | BindingFlags.Instance);

        // 反射获取 GravityHelper.GravityHelperModule.ShouldInvertPlayer 属性
        IsPlayerInvertedProperty = gravityHelperModuleType?.GetProperty("ShouldInvertPlayer", BindingFlags.Public | BindingFlags.Static);
    }
}

Info

如果需要反射获取的内容较多建议联系 Mod 作者, 让 Mod 作者添加相应的 ModInterop API.

完成这些后我们就可以访问和调用这些东西了:

SampleTrigger.cs
 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
[CustomEntity("MyCelesteMod/SampleTrigger")]
public class SampleTrigger : Trigger
{
    public SampleTrigger(EntityData data, Vector2 offset)
        : base(data, offset)
    {
        // 判断 GravityHelper 是否成功加载
        if (!MyCelesteModModule.GravityHelperLoaded)
        {
            throw new Exception("SampleTrigger requires GravityHelper as a dependency!")
        }
    }

    public override void OnEnter(Player player)
    {
        base.OnEnter(player);

        // 设置玩家重力
        object[] parameters = [2, 1f, false];
        MyCelesteModModule.SetPlayerGravityMethod.Invoke(MyCelesteModModule.PlayerGravityComponentProperty.GetValue(null), parameters);

        // 获取玩家是否在反重力状态下
        bool isPlayerInverted = (bool)MyCelesteModModule.IsPlayerInvertedProperty.GetValue(null);
        Logger.Log(LogLevel.Info, "MyCelesteMod", $"isPlayerInverted is {isPlayerInverted}!");
    }
}

这种方式和 ModInterop 类似, 可以在不直接引用目标 Mod 的程序集的情况下进行交互. 不过代码会变得更复杂, 更脆弱, 可读性也会降低. 此外, 目标 Mod 的一些改动可能会导致你的 Mod 不能按预期工作, 从而导致崩溃.

我们只建议只需要轻度交互时使用这种方式, 如果目标 Mod 有 ModInterop API 时更推荐去使用 ModInterop API.

跨 Mod 钩子

我们也可以为另一个 Mod 添加 IL 钩子, 参考 IL 钩子.

不过, 一般不鼓励像这样改变另一个 Mod 的行为. 安装 Mod 的用户通常希望它的行为与描述一致, 因此任何外部更改都应尽量少做. 此外, 这种方法比使用反射调用方法更加脆弱, 因为它依赖于签名和 IL 的相对稳定.