Skip to content

Commit

Permalink
更新项目模板 添加跨mod交互 (#7)
Browse files Browse the repository at this point in the history
* 更新项目模板 添加跨mod交互

* 补充跨mod交互 修复模板嵌套

* 修复一点点问题(

* 添加注释 跨 mod 钩子

* Update cross_mod_interactions.md

* 小修小补

* 临时移除新的模板

---------

Co-authored-by: Saplonily <[email protected]>
  • Loading branch information
ClearZer0 and Saplonily authored Dec 16, 2024
1 parent 32f1f7b commit cc90ee3
Show file tree
Hide file tree
Showing 7 changed files with 265 additions and 14 deletions.
249 changes: 249 additions & 0 deletions docs/advanced/cross_mod_interactions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,249 @@
# 跨 Mod 交互

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

## 依赖管理

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

```yaml title="everest.yaml"
- 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 的轻量性与灵活性, 建议尽可能减少必需依赖的数量.
如果一个依赖是可选的, 我们应该在使用被依赖的功能前检查它是否被成功加载.

一个可能的实现如下:

```cs title="MyCelesteModModule.cs"
public static bool GravityHelperLoaded;
public override void Load()
{
// 获取 GravityHelperModule 的元数据
EverestModuleMetadata gravityHelper = new()
{
Name = "GravityHelper",
Version = new Version(1, 2, 20)
};
// 判断 GravityHelper 是否成功加载
GravityHelperLoaded = Everest.Loader.DependencyLoaded(gravityHelper);
}
```

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



## ModInterop

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

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

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

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

### ModExportNameAttribute

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

下面我们新建一个 `MyCelesteModExports` 类进行示例:
```cs title="MyCelesteModExports.cs"
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:
```cs title="MyCelesteModModule.cs"
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` 的可选依赖.

```cs title="MyCelesteModAPI.cs"
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:
```cs title="AnotherCelesteModModule.cs"
using MonoMod.ModInterop;
public override void Load()
{
// 注册 ModInterop API
typeof(MyCelesteModAPI).ModInterop();
}
```

现在我们可以通过 `MyCelesteModAPI` 中导入的委托以调用我们希望使用的功能:
```cs
int myNumber = MyCelesteModAPI.GetNumber();
if (MyCelesteModAPI.MultiplyByTwo(myNumber) > 400)
{
MyCelesteModAPI.LogStuff();
}
```

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


<!--
## 直接程序集引用

### Cache

### lib-stripped
-->



## 通过 EverestModule 反射获取程序集

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

下面我们以 [`GravityHelper`](https://github.com/swoolcock/GravityHelper) 为例:
```cs title="MyCelesteModModule.cs"
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 = playerComponent?.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.

完成这些后我们就可以访问和调用这些东西了:
```cs title="SampleTrigger.cs"
[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 钩子](../hooks/adv_hooks.md).

不过, 一般不鼓励像这样改变另一个 Mod 的行为. 安装 Mod 的用户通常希望它的行为与描述一致, 因此任何外部更改都应尽量少做.
此外, 这种方法比使用反射调用方法更加脆弱, 因为它依赖于签名和 IL 的相对稳定.
2 changes: 1 addition & 1 deletion docs/coding_setup/basic_env.md
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,7 @@ ok, 我们前面几乎巴拉巴拉讲了几乎三千多个字, 但是依然没
DLL: MyCelesteMod.dll
Dependencies:
- Name: Everest
Version: 1.3971.0
Version: 1.4000.0
```
这些参数分别是:
Expand Down
14 changes: 4 additions & 10 deletions docs/coding_setup/code_reading.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,19 +51,13 @@

- `Uses` 直接依赖于
- `Used By` 被调用于
- `Read By` 被读取于
- `Assigned By` 赋值于
- `Expose By` 暴露于
- `Instantiated By` 被实例化于
- `Extension Methods` 拓展方法

<!-- umm 等后面再写(?>
## 反编译代码的结构分析
wip
至于下面的后续会移除所有钩子相关 标题可能会改成 反编译代码中的"特殊"语法
<-->

## 奇奇怪怪的昵称与代码
## 反编译代码中的"特殊"语法

一些情况下, 阅读由反编译器生成的代码时可能不是那么顺利, 那么这一节会简单说一些反编译器生成的代码与通常的 C# 代码不一样的地方.

Expand Down Expand Up @@ -97,15 +91,15 @@ public void orig_ctor(EntityData e, Vector2 offset)
!!! info
`ctor` 这个奇怪的缩写来自单词 `constructor`, 直译即 `构造器`.

所以我们通常也会用 `.ctor` `ctor` 来指代构造函数, `.cctor` `cctor` 指代静态构造函数. 此外, Visual Studio 中有个自带的代码片段就是 `ctor`,
所以我们通常也会用 `.ctor` / `ctor` 来指代构造函数, `.cctor` / `cctor` 指代静态构造函数. 此外, Visual Studio 中有个自带的代码片段就是 `ctor`,
在类中打出 ctor 并双击 Tab 键, vs 就会自动生成该类的构造函数, 这里的 `ctor` 来源也就在此.

!!! info
在后面的钩子节我们没有探讨过构造函数如何钩取, 在这里你可能就会明白, 钩取名字为 `ctor` 的函数就是钩取了构造函数, 静态构造函数同理.

### orig_*

还有一些函数以 `orig_` 开头, 这其实是 everest 自己"钩取"的函数. 在这里, 比如 `Player.Update` 方法就被 everest 进行了"钩取",
还有一些函数以 `orig_` 开头, 这其实是 everest 自己"[钩取](../hooks/hook.md)"的函数. 在这里, 比如 `Player.Update` 方法就被 everest 进行了"钩取",
而钩子函数本体就是 `Player.Update`, 而对应我们钩子的 `orig` 委托在就体现为 `orig_Update` 方法.

```cs title="Player.Update (像钩子本体一样!)"
Expand Down
2 changes: 1 addition & 1 deletion docs/hooks/hook.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# 钩子, 阅读代码, demo1
# 钩子

<!--
- 原计划是自动化构建介绍于本节
Expand Down
3 changes: 3 additions & 0 deletions docs/misc/change_log.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,6 @@
* 分离阅读代码
* 分离 StateMachine

### 2024.12.15
* 更新项目模板
* 添加跨 Mod 交互
7 changes: 5 additions & 2 deletions docs/misc/to_do_list.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,15 @@

| 计划 | 状态 |
| ----------------------------- | ------ |
| 完善阅读代码相关 | 进行中 |
| 跨 mod 交互 / ModInterop 相关 | 准备中 |
| 完善跨 mod 交互 | 进行中 |
| 添加测试地图 | 进行中 |
| 更新基础环境配置-模板使用 | 准备中 |
| everest 自带事件 | 计划中 |
| publicizer 的使用 | 计划中 |
| 分离已有的组件进行重写 | 计划中 |
| 完善 VisualStudio C# 调试 | 计划中 |
| 重构 hook 节 | 计划中 |
| 分离 DynamicData 相关 | 计划中 |
| 完善 StateMachine | 计划中 |
| 添加 Effext 相关 | 计划中 |
| 添加 Collider 相关 | 计划中 |
2 changes: 2 additions & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ nav:
- 更多EC: "basics/ec_common.md"
- Flag, Tag, Tracker: "basics/flag_tag_tracker.md"
- Session, Settings, SaveData: "basics/session_settings_savedata.md"
- 进阶:
- 跨 Mod 交互: "advanced/cross_mod_interactions.md"
- 实战:
- 简单自定义实体: "coding_challenges/simple_entity.md"
- 简单自定义Trigger: "coding_challenges/simple_trigger.md"
Expand Down

0 comments on commit cc90ee3

Please sign in to comment.