Unity 游戏的模组支持开发总结

🐬前言

模组支持的核心需求主要包含以下两部分:

  • 资源模块: 允许模组作者修改/新增游戏配置, 包括角色, 技能, 道具等
  • 编程模块: 允许模组作者通过脚本扩展游戏逻辑

本篇自然以我开发的《魔剑镇魂曲重制版》为例, 因为我也只是一个新手菜鸟, 这次的目标便是完整地实现上述两方面功能即可

🐤为模组添加 "资源模块" 支持

这一部分的思路其实非常简单, 就是游戏内指定一种文本语法, 让模组作者使用这种语法编写配置文本文件, 我们在游戏中按照对应的语法格式读取文本文件, 并添加到游戏数据库中即可

注: 这里的数据库并不是网站开发中的数据库, 而是指游戏内的静态数据, 其实这才是数据库一词的真实含义, 一个完全静态数据的集合就是数据库

🤠本体数据库的格式选择: ScriptableObject

我这里使用了 Unity 提供的 ScriptableObject, 为什么使用这个呢?

传统的数据配置大多使用 Excel, 充分利用 Excel 那丰富的表格功能, 最大程度提升策划们的工作效率, 同时也提升其他人的阅读效率, 但是 Excel 无法参与 Git 版本管理, 因此大多项目组会将 Excel 文件视为源文件, 真正参与到项目中的是 Excel 导出的 CSV 文件, 这是一种纯粹的带语法格式的文本文件, 虽然语法格式就是简单的用 tab 分割, 但这也是一种语法格式, 这样的 CSV 就可以参与到项目管理中了, Excel 则被纳入了项目外的单独管理中

但实际情况是, 我的项目很小, 从头到尾仅有我一人进行开发, 全部的任务都由我一人完成, 没有其他的阅读者和协作者, 因此保证我的开发效率才是最重要的, 而且使用 Excel 还要编写 Excel 到 CSV 和 CSV 到 Excel 的自动化转化逻辑, 这部分对于我这个单人项目而言, 完全没必要

按照这个思路下去, 最优的选择自然就是 Unity 原生支持的 ScriptableObject 了, 我还可以很方便快速地编写 C# 脚本进行批处理, 这种效率可不是 Excel 能比的 (因为我使用 Excel 并不熟练, 只会基础操作)

还有一个更重要的点, 使用 csv 文件还需要编写对应的 csv 解析逻辑, 不仅如此, 还得写一个对应的数据类, 将 csv 中的数据读取到数据类中, 但是既然都需要编写数据类了, 那为什么不直接将这个数据类继承自 ScriptableObject 呢, 这样都不需要编写解析逻辑了, 直接通过 Unity 读取 ScriptableObject 即可, 一步到位!

💊模组数据库的格式选择: Json

这里最开始选择的是最通用的文本文件, 语法自定义, 最终的配置文件如下:

三暗影|1.0

content|role|莱希尔

role|icon|male|textures/48_头像_莱希尔.png

role|icon|female|textures/48_头像_莱希尔.png

role|portrait|male|textures/48_立绘_莱希尔.png

role|portrait|female|textures/48_立绘_莱希尔.png

这种自定义语法的优点是非常直观, 模组作者一看就懂, 我只要说明有哪些字段可用即可

但是问题就是想要支持任何一个修改点都需要我编写代码, 比如上面的示例便是支持修改角色的立绘和头像, 仅仅这一个修改点就要写一个类, 那无论是职业, 角色, 道具, 地图, 关卡, 关卡事件, 关卡事件的条件, 关卡事件的行为, 音乐, 技能等等, 每一个模块中都有十几甚至几十个数据点, 如果每个数据点都要编写代码支持, 这个任务量可想而知, 于是此方案废弃

之后开始选择更加成熟的语法文件, 最终在大量的语法文件中选择了 json 文件, 其他的像 xml, ini, yaml 等都或多或少存在一些问题, 同时 json 也是我最熟悉, 最喜欢的格式, 因为它既兼顾了可读性, 又有极强的规范性, 且非常成熟, 各大编辑器都支持 json 文件, 更重要的是 Unity 唯一支持的序列化就是 json, Unity 提供的 JsonUtility 类是目前已知效率最高的 json 序列化器, 不使用的话真的是有点暴殄天物, 所以 json 称为了不二之选

什么 ? 你说 Unity 的 JsonUtility 兼容性不行 ? 那是你不会用, 经过我扩展的 JsonUtility 目前已经没有不支持的类型了, 再说除了 Unity 的 JsonUtility 你还知道哪个 Json 序列化器原生支持序列化 Unity 的专有类型呢 ?

当然还有最最最重要的一点, 那就是 支持配置数据的局部修改

Unity 的 JsonUtility 有一个极方便的方法: FromJsonOverwrite 使用此方法便可以允许模组作者编写 Json 文件时, 仅需要编写要修改的字段, 如果字段不需要修改, 那就不需要书写, Unity 会自动使用原数值, 大大提升了模组作者的开发效率

比如我只想修改物品的价格, 那么只要填写 id 和 price 两个字段即可, 其他的字段完全不需要, 就是如此简单, 仅 2 行就写完了一个物品

1
2
3
4
{
"id": "core.item.001.飞刀",
"price": 400
}

🍓实现资源模块的过程

接下来阐述资源模块的实现过程, 包括游戏本体的处理和模组部分的处理

本体: 编写数据库管理系统

游戏本体的数据库管理系统只需要满足一个最重要的需求即可: 热重载, 即可以在运行时重置数据库数据, 并重新加载数据库

因为模组在开启后, 是可以被玩家关闭的, 那么此时就需要重置数据库, 或者重新加载数据库, 不能让已关闭的模组中的数据污染数据库

如何实现数据库的热重载呢 ?

数据库统合管理器

第一步, 编写数据库管理器, 用于统合每一个小的数据库, 同时提供全局的加载方法

1
2
3
4
5
6
7
8
9
10
11
12
loadTaskList.Add(SO_Affix.Collect(labelReference));
loadTaskList.Add(SO_AudioClip.Collect(labelReference));
loadTaskList.Add(SO_Bonus.Collect(labelReference));
loadTaskList.Add(SO_Equip.Collect(labelReference));
loadTaskList.Add(SO_GameMode.Collect(labelReference));
loadTaskList.Add(SO_Item.Collect(labelReference));
loadTaskList.Add(SO_Job.Collect(labelReference));
loadTaskList.Add(SO_Level.Collect(labelReference));
loadTaskList.Add(SO_Map.Collect(labelReference));
loadTaskList.Add(SO_Role.Collect(labelReference));
loadTaskList.Add(SO_Skill.Collect(labelReference));
loadTaskList.Add(SO_Tile.CollectCastle(labelReference));

颗粒数据库的加载逻辑

第二步, 编写每一个小数据库的加载逻辑, 我这里使用的是 Unity 提供的 Addressable 系统

1
2
3
4
5
6
7
8
9
10
11
12
13
public static Dictionary<string, SO_Item> DB { get; } = new Dictionary<string, SO_Item>();

public static Task Collect(AssetLabelReference labelReference)
{
DB.Clear();
labelReference.labelString = nameof(SO_Item);
return Addressables.LoadAssetsAsync<SO_Item>(labelReference, LoadAsset).Task;
}

private static void LoadAsset(SO_Item asset)
{
DB[asset.id] = Instantiate(asset);
}

这里有一个非常非常非常重要的点: DB[asset.id] = Instantiate(asset);

这里为什么不能直接保存读取的 asset, 而是要 Instantiate(asset) 呢? 这是因为 Unity 引擎中, ScriptableObject 的数值一旦被修改, 必须关闭游戏再次启动才能将数据还原, 因此一旦直接保存了 asset, 后面加载模组数据时, 有的模组修改了其中的数据, 那么这份数据就被永久修改了, 即使是重新加载这份资源, 也还是修改后的数值, 除非关闭游戏

但是每次进行模组的切换肯定不能让玩家重启游戏, 因此这里必须实例化一份新的资源, 游戏仅使用新实例化出来的副本, 而不是使用原数据, 只要注意好这点, 本体的数据库管理就完成了!

本地: 拆分数据结构

什么是拆分数据结构 ? 要怎么拆 ? 为什么要拆 ?

拆分数据结构就是把要开放给模组作者的字段和不开放给模组作者的字段拆开, 因为我的项目是直接使用的 ScriptableObject 文件, 因此数据类中包含 Sprite, AudioClip 这种资源字段, 而这些字段是不能直接开放给模组作者的, 模组作者使用的字段应该是 string, 即资源的路径, 因此需要这样拆分开数据类

看看下面的 3 个类, 应该一下子就明白了吧, 真正要开放给模组作者的便是 DatabaseItem

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class SO_Item_Base : ScriptableObject
{
public string id;
public string displayName;
}

public class SO_Item : SO_Item_Base
{
public Sprite icon;
}

public class DatabaseItem : SO_Item_Base
{
public string iconPath;
}

本地: 资源标识规范化

游戏内资源的标识有几种方案选择

枚举

枚举方案, 即硬编码方案, 将数据直接硬编码到程序中, 优点显而易见, 标识绝对不会出错, 一旦出错, 编译都无法通过, 缺点也是致命的, 无法运行时动态增删, 对于模组支持而言是致命的, 此方案舍弃

数字

数字方案, 比较常用的方案, 每一个资源都有自己的数字标识, 可以动态增删, 但是对于模组支持而言也有致命缺陷: 标识冲突

因为使用数字标识, 那么模组中新增的资源也必须使用数字标识, 这样的话, 模组作者之间的标识冲突便成为了必然, 毕竟谁也没有规定谁必须使用哪个范围内的数字, 我用了 100 - 200, 他也可以用 100 - 200, 这种冲突完全不可控, 此方案舍弃

字符串

在前两个方案都无法满足的情况下, 就只能使用字符串了, 但是字符串作为标识也不能随便使用, 而是必须要有规范, 即使用: 语义字符串

语义字符串

语义字符串即字符串是有语义的, 我直接以我项目中的标识来讲解, 举例两个目前使用的标识

这个是本体中资源的标识: core.role.001.kirito

这个是模组中资源的标识: mod.kuroha.role.001.asuna

  1. 第一组语义为本体还是模组, 本体统一使用 core, 模组则统一要求作者们使用 mod
  2. 第二组语义为模组作者的名字, 此语义仅模组使用, 游戏本体省略
  3. 第三组语义为资源类型, 这里举例为 role
  4. 第四组语义为资源序号, 这里举例为 001
  5. 第五组语义为资源名称, 这里举例为 kirito 和 asuna

通过这样的语义标识可以彻底避免游戏本体和模组作者以及各个模组作者之间的标识冲突问题

模组: 添加语法文件解析

这个步骤非常简单, 目标就是读取模组的 json 文件并解析为对应的资源, 核心点就是利用 FromJsonOverwrite 方法实现部分配置的修改, 下面是我的模组数据结构

模组数据结构

最重要的点就是让模组的作者配置好 json 所代表的数据类型, 这样我们在解析时就可以按照对应的标识解析为对应的数据类型了, 以关卡数据结构为例, 整个的解析方法就这么几行, 非常简单:

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
public class DatabaseLevel_Parser : IParser
{
private SO_Level coreAsset;
private SO_Level newAsset;

public bool IsMatch(ModContent content)
{
coreAsset = null;
newAsset = null;
return content.type == "database_level";
}

public bool IsCoreAsset(string jsonText)
{
newAsset = UnityEngine.ScriptableObject.CreateInstance<SO_Level>();
UnityEngine.JsonUtility.FromJsonOverwrite(jsonText, newAsset);
return SO_Level.DB.TryGetValue(newAsset.id, out coreAsset);
}

public Task Parse(string jsonText)
{
if (IsCoreAsset(jsonText))
{
UnityEngine.JsonUtility.FromJsonOverwrite(jsonText, coreAsset);
}
else
{
SO_Level.DB[newAsset.id] = newAsset;
}

return Task.CompletedTask;
}
}

模组: 添加图片和音乐外部加载支持

模组中支持模组作者自己添加精灵图和音乐, 自然需要对应的代码支持, 这个我不再废话什么了, 直接上代码

导入图片支持

1
2
3
4
5
6
7
var texture = new Texture2D(2, 2, TextureFormat.RGBA32, false);
texture.LoadImage(await File.ReadAllBytesAsync(texturePath));
texture.Apply();

var rect = new Rect(0, 0, texture.width, texture.height);
var pivot = new Vector2(0.5f, 0.5f);
var sprite = Sprite.Create(texture, rect, pivot);

导入音频支持

1
2
3
4
5
6
7
8
9
10
11
var URI = $"file://{audioPath}";

using (var assetRequest = UnityEngine.Networking.UnityWebRequestMultimedia.GetAudioClip(URI, audioType))
{
await assetRequest.SendWebRequest();

if (assetRequest.result == UnityEngine.Networking.UnityWebRequest.Result.Success)
{
audioCache.Add(subPath, UnityEngine.Networking.DownloadHandlerAudioClip.GetContent(assetRequest));
}
}

🍉为游戏添加 "编程模组" 支持

添加模组支持主要分两部分, 一部分是如何给予模组作者一个可编译, 可编写, 可生成的编程环境, 让模组作者可以直接引用项目中的 API, 另一部分是如何加载模组作者的 DLL, 将模组作者的逻辑注入到自己的逻辑中

这里不考虑非 C# 语言, 因此下文中默认模组作者编写的逻辑最终会生成 DLL 文件

如何加载模组作者 DLL 中的逻辑

先来说比较简单的第二部分, 因为使用的是 DLL 文件, 因此最终的加载肯定是使用 Assembly.Load 函数, 但是众所周知, 选择使用 Mono 时, 可以直接使用此函数, 但是当选择 IL2CPP 时, 项目是不支持上面的函数的, 所以这里必须使用 HybridCLR 插件, 使用此插件后便可以使用 Assembly.Load 了, 关于如何使用 HybridCLR 插件, 我就不赘述了, 因为官方文档实在是太详细了, 完全没有需要补充的点, 这里直接发出链接, 照着教程走, 几分钟就搞定了! 当然前提是你有优雅的代码管理习惯, 不然, 可能项目要大改!

HybridCLR 官网

HybridCLR 官方手册

json 方案

最简单的方案是和前面的资源模块一样, 让模组作者把 DLL 中类的全名, 全名就是包含命名空间名, 以及类的功能类别配置到 json 中, 只要知道了类的全名和功能类别, 我们就可以加载对应的类, 并将其注入到对应的功能模块中, 简单可行!

反射方案

自行反射实现了对应接口的类, 之后将这些类直接注入到功能模块中, 更加简单直接, 对模组作者非常友好, 不需要模组作者自行配置, 可行!

特性方案

提供多个特性, 让模组作者可以对自己编写的类进行修饰, 简明易读, 之后我们反射对应的特性, 将类注册到对应的功能模块中, 可行!

上述三种方案均可实现逻辑注入, 可自行选择方案

必须实现的逻辑

目前我认为, 除了按照项目特点开放出来一些接口, 以供模组作者实现以外, 还有一个必须实现的功能就是 MonoBehaviour 脚本的编写, 允许模组作者自行编写 MonoBehaviour, 使用 Unity 的生命周期实现任意的自定义逻辑

但是这样会有问题, 就是可能会触及到敏感代码, 如果项目中确实有敏感代码, 千万不能开放 MonoBehaviour, 相反便建议支持 MonoBehaviour, 毕竟这是最高自由度的编码, 下面放一段简单的逻辑注入代码

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
35
36
37
38
39
40
41
42
var modAssembly = Assembly.Load(File.ReadAllBytes(Path.Combine(Global.MOD.currentModFolder, modData.jsonPath)));

foreach (var type in modAssembly.GetTypes())
{
if (type.IsDefined(typeof(Mod_SkillAttribute), false))
{
SkillRegister(type);
}
else if (type.IsDefined(typeof(Mod_AffixAttribute), false))
{
AffixRegister(type);
}
else if (type.IsDefined(typeof(Mod_MonoBehaviourAttribute), false))
{
MonoRegister(type);
}
}

private static void MonoRegister(Type type)
{
var monoName = type.Name;

if (Global.MOD.modMonoBehaviourTable.TryGetValue(monoName, out var mono))
{
DebugUtil.LogWarning($"已存在类型: {mono}", DebugUtil.YELLOW);
return;
}

var monoGameObject = new GameObject(monoName);
monoGameObject.transform.SetParent(Global.MOD.modMonoBehaviourRoot.transform);
Global.MOD.modMonoBehaviourTable[monoName] = monoGameObject.AddComponent(type) as MonoBehaviour;
}

private static void AffixRegister(Type type)
{
Global.AFFIX.RegisterLogic(Activator.CreateInstance(type) as Affix);
}

private static void SkillRegister(Type type)
{
Global.SKILL.RegisterLogic(Activator.CreateInstance(type) as Skill);
}

如何给予模组作者 DLL 开发环境

这里就是本文的重中之重了, 如何给予模组作者一个可编码, 可编译, 可生成的游戏开发环境, 就像 Unity 给我们开发者提供了开发环境一样, 我们也要给模组作者提供开发环境

提供开发环境的意思就是, 模组作者可以直接调用我们项目中的代码, 可以直接编译通过, 生成 DLL 文件, 那么我们就需要把代码开放出来, 对吧 ?

啊 ? 这对吗 ? 这不对吧 ? 把代码开放出来 ? 你 TM 在开玩笑吗 ? 你干脆让我开源算了 ...

好事做到底, 直接开放项目, 让他们可以重打包, 发布游戏赚钱, 我直接成为别人的打工人算了 ... 岂不美哉 ?

哈哈哈, 冷静, 冷静 ... 听我慢慢说 ...

因为 C# 是编译型语言, 所以想要让模组作者可编码, 可编译, 可生成, 提供游戏内的基础库是必须的, 不然他们根本无法编码, 但是我们也不能暴露自己的代码实现呀, 毕竟 DLL 发出来就和代码裸奔没什么区别, 那么有什么办法既可以满足模组作者的需要, 又可以满足我们开发者的需要呢 ?

有, 有的, 兄弟!

那就是声明式程序集

声明式程序集

声明式程序集中仅包含声明, 没有任何的实现, 也就是说这个 DLL 仅能用来辅助编译, 无法被加载, 被识别, 因为里面是空的, 只有一堆声明信息, 这样就满足双方的需求了!

那么:

  1. 如何生成声明式程序集呢 ?
  2. 生成声明式程序集时要注意什么呢 ?
  3. 无法被加载又是什么意思, 会有哪些坑呢 ?

如何生成声明式程序集

声明式程序集其实就是 "类库" 类型项目的构建结果

回想下最初学习编程时, 是不是面对一个黑窗口 (cmd) 来查看编程结果, 那种项目便是控制台类型项目, 最终的构建结果是一个 exe 文件, 而这里的声明式程序集就是一个 "类库" 型的项目, 最终的构建结果是 DLL 文件

我用 Rider 打开类库项目, 尝试生成声明式程序集, 发现并不行, 只能生成完整程序集, 于是目前为止, 我所知道的能够一键生成声明式程序集的软件就是 Visual Studio

为什么不说 VS Code 呢, 因为这个软件需要手动配置, 并不是一键生成, 对新手并不友好, 也就是对我不友好, 同时 Unity 想要打包 IL2CPP 的话, 需要安装 C++ 编译器, 还需要安装 Windows 10 SDK, 如果仅使用 VS Code , 那么这些环境都是需要自己手动去折腾的

于是统合上述全部需求, 既可以一键管理开发环境, 一键管理编译器, 一键管理 Windows SDK 的安装卸载, 又可以编译生成声明式程序集的 Visual Studio 自然成为了首选

接下来我会以自己项目为例, 一步一步讲述如果构建自己游戏的 SDK

声明式程序集生成步骤

安装开发环境

Visual Studio Instanller 中安装 .Net 桌面开发 组件, 仅安装基础组件即可, 其它的附加组件其实都不需要, 当然安装了也不是不行

新建解决方案

启动 Visual Studio 2022 , 选择创建新项目

新建解决方案3

选择 "类库" 类型, 注意项目图标右上方的语言标识, 记得选择 C# 的, 不要选择了 VB 或者 F# 的

新建解决方案1

填写名称, 选择项目目录

新建解决方案2

框架选择 .Net Standard 2.1, 选择 .Net Standard 2.0 应该也可以, 我没试过

新建解决方案3

删除默认项目

删除解决方案中的默认项目, 右键项目, 在菜单中选择移除

移除后, 项目所以不再属于此解决方案中, 但是项目本身的文件还在存在于磁盘中的, 建议找到目录中的项目文件, 也一并删除

新建解决方案4

新建 Unity 项目

按照 Unity 项目中程序集的划分来建立项目, 比如我的项目中划分的运行时项目有 7 个, 那么就需要新建 7 个项目, 在解决方案菜单上右键, 选择添加, 新建项目, 只有和游戏运行相关的需要在这里参与编译, 生成声明式程序集, 其他不参与的编辑器程序集则不需要新建

新建解决方案5

填入在 Unity 中填写的程序集名称, 比如和 Unity 中的保持一致, 至于为什么, 我最后解释, 这里先这么做

新建项目后, 记得把项目中默认的 Class1.cs 删除, 因为我们不需要编写额外代码, 到时候直接把项目代码拷贝过来就可以了

我的项目中划分的运行时项目有 7 个

  • ARModule
  • Kuroha.PNG
  • Kuroha.GIF
  • Kuroha.KConsole.Runtime
  • Kuroha.UI.Runtime
  • Kuroha.Utility.Runtime
  • SwordRequiem.Runtime

全部项目新建完成后截图如下

新建解决方案6

将 Unity 中的代码拷贝到对应的项目中

将 Unity 中的代码拷贝到相对应的项目中, 如果你会使用 Windows 软链接, 可以使用软链接直接同步过去, 当然此时项目中将会有大量的报错, 没关系, 这是因为没有引用相关的 Unity 程序集导致的, 代码拷贝完成后的截图如下

新建解决方案7

引入依赖的 Unity 类库和第三方插件库

下一步就是解决对 Unity 类库和第三方插件库的依赖问题, 解决原理就是直接让 Unity 打一次包就可以了, Unity 打包后生成的 DLL 肯定是全的, 不然游戏运行不起来呀, 对吧 ?

如果你的项目是 Mono 项目, 则直接打一个包, 打包后程序的 Managed 文件夹中会生成项目全部的 DLL 文件, 将这些文件拷贝到一个特定的目录, 让 SDK 解决方案中的每一个项目都去引用他们即可, 需要注意, 千万不要一股脑全选引用, 因为很多是重复引用, 是不能添加的, 按照你项目自己的报错信息添加依赖的 DLL 即可

如果你的项目是 IL2CPP 项目怎么办呢 ? 不用担心, 使用 HybridCLR 插件后, 执行 HybridCLR/Generate/All 菜单, 生成后, 在 HybridCLRData/HotUpdateDlls 中可以找到全部的 DLL 文件, 将这些文件拷贝到一个特定的目录, 让 SDK 解决方案中的每一个项目都去引用他们即可, 需要注意, 千万不要一股脑全选引用, 因为很多是重复引用, 是不能添加的, 按照你项目自己的报错信息添加依赖的 DLL 即可

在项目的 依赖项 上右键, 选择 "添加项目引用"

新建解决方案8

点击右下角的 "浏览" 添加对应的引用即可

新建解决方案9

解决项目间的依赖问题

这个就更简单了, 同样是在项目的 依赖项 上右键, 选择 "添加项目引用", 不过在打开的页面中选择解决方案, 直接在列出的已有项目中选择依赖的项目即可

新建解决方案10

注: 因为逐个项目修改引用很慢, 如果知道怎么修改 .csproj 文件, 你可以直接编辑 .csproj 文件, 实现以超快速度设定完成项目引用!

设定语言版本

至此应该会是会有很多报错, 比如使用了高版本的语言特性, 所以我们需要设定一下编译时的语言版本, 由于语言版本无法在 "属性" 中修改, 所以需要直接编辑 csproj 文件, 双击项目就可以打开 csproj 文件了, 在 PropertyGroup 标签内添加一行, 注意, 每一个项目的 csproj 文件都要修改!

1
<LangVersion>latest</LangVersion>

设定生成声明式程序集

默认情况下类库项目仅生成完整的 DLL. 如果要生成声明式程序集 DLL 需要编辑 csproj 文件, 在 PropertyGroup 标签内添加一行, 注意, 每一个项目的 csproj 文件都要修改!

1
<ProduceReferenceAssembly>true</ProduceReferenceAssembly>

设定生成声明式程序集的输出目录

默认情况下声明式程序集 DLL 会生成到各自项目的 obj 文件夹中, 为了方便拷贝, 可以统一生成到一个目录下, 要修改输出目录首先要启用自定义输出目录, 之后配置输出目录, 同样是编辑 csproj 文件, 在 PropertyGroup 标签内添加两行, 注意, 每一个项目的 csproj 文件都要修改!

1
2
<BaseOutputPath>..\..\SwordRequiemDLL</BaseOutputPath>
<ProduceReferenceAssemblyInOutDir>true</ProduceReferenceAssemblyInOutDir>

到目前为止, csproj 文件的 PropertyGroup 标签大概是这样:

1
2
3
4
5
6
7
8
<PropertyGroup>
<TargetFramework>netstandard2.1</TargetFramework>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<BaseOutputPath>..\..\SwordRequiemDLL</BaseOutputPath>
<ProduceReferenceAssembly>true</ProduceReferenceAssembly>
<ProduceReferenceAssemblyInOutDir>true</ProduceReferenceAssemblyInOutDir>
</PropertyGroup>

补充编译宏

虽然已经添加了绝大多数设置和引用了, 但是到这里的时候, 项目应该还是报错的, 无法生成

这是因为 Unity 中我们一般是多平台开发的, 里面有很多宏控制, 区分不同平台

尤其是接入了 Steam 时, 还有 Steam 的宏控制, 因此需要设定一下宏, 那这里的宏究竟要添加哪些呢 ?

这个是按照自己的项目来的, 前面不是打包了一次吗 ? 为了生成 DLL 的时候, 就使用那个项目那时的宏状态, 打包的时候用了什么宏, 这里就要写入什么宏

项目处右键, 打开属性, 找到 条件编译符号, 添加对应的宏即可

新建解决方案11

最后点击 生成/重新生成解决方案 即可生成声明式 DLL, 可以在输出目录的 ref 文件夹中找到

新建解决方案12

这些 DLL 就可以发给模组作者了

前面不是让你把需要引用的 DLL 全部单独放到一个文件夹里面嘛, 这里最好是连前面引用的文件夹中的 DLL 一起发给模组作者, 这样我们游戏的 SDK 环境就完整了!

这样模组作者就可以通过新建类库项目, 引用我们给的 DLL 文件, 就可以编写代码了!

⚡为什么要和 Unity 中的程序集划分保持一致 ?

以我自己的项目为例, 我在 Unity 项目中划分了 7 个程序集:

  • ARModule
  • Kuroha.PNG
  • Kuroha.GIF
  • Kuroha.KConsole.Runtime
  • Kuroha.UI.Runtime
  • Kuroha.Utility.Runtime
  • SwordRequiem.Runtime

现在, 我的 SDK 项目 (用于为 Mod 作者生成声明式程序集的项目) 中只划分了一个程序集: SwordRequiem.SDK

我生成声明式程序集, 得到一个 SwordRequiem.SDK.dll, 并将它发给 Mod 作者, Mod 作者在这个环境中编写逻辑, 编译得到 mod.dll, 其中 mod.dll 依赖于 SwordRequiem.SDK.dll

然而, 当游戏尝试在运行时加载这个 mod.dll 时, 会直接报错: 无法找到名为 SwordRequiem.SDK 的程序集 提示缺失引用!

尝试解决下 ?

我们可以设想, 是否可以把 SwordRequiem.SDK.dll 一并保留, 运行时在加载 mod.dll 之前, 先手动加载这个 SDK DLL ?

理论上可以, 但实际上不行 -- 这就是声明式程序集的局限:

声明式程序集中只包含类型的声明, 而不包含任何逻辑实现也就是说它只适用于编译期引用, 而在运行时根本无法被加载

当你尝试在运行时使用 Assembly.LoadFrom("SwordRequiem.SDK.dll") 加载它时, CLR 会直接报错, 提示这是一个非法程序集, 通常为 BadImageFormatException

那怎么办 ?

我们再来看看 CLR 如何判断程序集引用:

程序集的识别是基于 Assembly Identity, 它包含以下字段:

  • Name ✅
  • Version
  • Culture
  • PublicKeyToken

虽然完整规则包含版本、公钥等, 但大多数 Unity 项目未启用这些, 主要就是靠 Name 匹配程序集

✅ 正解: 保持程序集划分一致

如果你让声明式 SDK 的输出程序集与 Unity 的原始程序集保持一致, 那就不会有这个问题了, Mod 编译时依赖的是 SwordRequiem.Runtime, 游戏运行时自然已经加载了真实的 SwordRequiem.Runtime.dll, CLR 会将 mod 中的类型引用绑定到原始实现, 无需再手动加载 SDK DLL, 如此一来, mod.dll 就可以毫无障碍地被加载与运行了

✅ 小结

Mod 编译时依赖的程序集名称必须和游戏运行时实际加载的程序集完全一致

因此, 声明式程序集必须划分为多个与 Unity 原始项目一致的模块, 不能统一为一个 SwordRequiem.SDK, 否则就会造成运行时绑定失败