ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

从零构建Rimworld Mod:项目源码与Harmony补丁实战

从零构建Rimworld Mod:项目源码与Harmony补丁实战

简介:面向环世界(Rimworld)模组开发入门者,以及希望了解游戏 Mod 工程结构的编程爱好者,这份项目源码包以“瘟疫测试枪”为完整案例,从 About 目录、Defs 数据定义到 Assemblies 程序集搭建出可运行的 Mod 骨架,结构上高度贴合官方推荐规范。读者配合 Visual Studio 或 JetBrains Rider 打开工程后,可逐行研读 C# 代码与 XML 配置,依次学习程序集构建、About/Defs 简介修改、本地化翻译,并通过反编译游戏代码理解内部逻辑,掌握从编写到测试的完整排错思路,最终得到一把可实际装入游戏的瘟疫测试枪。包内共 11 个文件,以 C# 源文件与 XML 数据定义为主,另有 csproj 工程文件、gitignore 忽略规则、Markdown 说明和 HTML 预览页等,压缩包整体仅 16KB,轻量、无冗余,便于逐文件对照教程学习。目前已有 212 人学习下载,尤其适合刚接触模组开发、想获得可直接运行和改造示例工程的读者快速上手并进入实战。

1. Rimworld Mod制作:为什么"项目源码"比单个Defs文件更有价值

Rimworld Mod制作,很多人第一反应是改XML数值或者复制现成的补丁,但真正能把Mod做成自己的东西、能长期维护的人,几乎都是从一份完整项目源码起步的。项目源码意味着C#工程、Defs目录、About清单、Harmony补丁层被统一组织起来;临时改一个Def只能算配置,源码级开发才能控制游戏逻辑本身。这份教程从一份可复现的DemoMod出发,拆解源码结构、C#与XML的分工、Harmony挂载方式,以及最容易翻车的五类问题。它适合想从"复制粘贴Defs"升级到"自己写逻辑"的Mod作者,也适合想理解Rimworld Mod运行机制、准备做深度定制的从业者。整个Mod的复杂度由项目源码决定,这也是这篇文章标题里"项目源码"四个字的分量所在。

2. 搭建Rimworld Mod开发环境:从游戏目录到IDE的完整配置

2.1 先搞清Mod运行的三个容器

Rimworld Mod运行环境没有常规意义的"插件入口"。游戏启动时,引擎会扫描Mods目录下每个Mod文件夹,按mod列表顺序加载Assemblies里的DLL,同时解析Defs目录下的XML。这带来三个直接后果:DLL不需要Main函数,游戏会调用所有标注了[StaticConstructorOnStartup]的静态构造函数;XML里定义的ThingDef、RecipeDef等,在C#代码里会被还原成对应类型的对象;日志统一写到玩家专属目录下的output_log.txt。理解这三点,你就明白为什么很多老Mod作者强调"环境搭建决定成败"——不是IDE配置有多难,而是加载机制和你的直觉不一样。

配置分四步走。第一,找到游戏安装目录,把游戏根目录作为基准。第二,确认Mod文档结构,Mod必须包含About/About.xml来完成识别,否则游戏连名字都认不出来。第三,创建C#类库工程,目标框架选net48;如果IDE没有.NET Framework选项,可以直接改.csproj里的TargetFrameworkVersion。第四,引用游戏程序集,把游戏目录下Managed文件夹里的Assembly-CSharp.dll、UnityEngine核心DLL、0Harmony.dll加进引用。注意Unity版本不同,Managed目录下的文件清单会有差异,新手最容易犯错的是只引用Assembly-CSharp.dll却忘掉它的依赖项,导致编译通过但运行时报TypeLoadException。

配置完成后,Mod目录结构大致是这样:

Mods/DemoMod/ ├── About/ │ ├── About.xml │ └── Preview.png ├── Assemblies/ │ └── DemoMod.dll └── Defs/ └── Things/ └── DemoDefs.xml

上面是第一层布局:About是识别文件,游戏靠它显示Mod名、作者与版本;Assemblies放编译产物;Defs放XML定义。第一版Demo不必做Patches目录,但Defs和Assemblies两个目录一定不能缺,否则游戏会认为这是个坏Mod。很多人在这一步翻车,是因为在IDE里建的是控制台程序,结果编译出一个带Main入口的exe,放进Assemblies后游戏加载时没有任何反应,日志也不报错,最后才发现方向从一开始就错了。

2.2 创建最小C#工程:从csproj到第一个DLL

我建议直接用类库模板,不要选控制台程序。控制台模板默认生成Program.Main,而Rimworld不需要这个入口,反而容易引入无关引用。把.csproj里的OutputPath指向Mod的Assemblies目录,是省去每次手动拷贝的习惯动作。下面是最小csproj的关键片段:

<Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <TargetFramework>net48</TargetFramework> <OutputPath>..\Mods\DemoMod\Assemblies</OutputPath> </PropertyGroup> <ItemGroup> <Reference Include="Assembly-CSharp"> <HintPath>..\RimWorld\RimWorld_Data\Managed\Assembly-CSharp.dll</HintPath> <Private>false</Private> </Reference> <Reference Include="0Harmony"> <HintPath>..\RimWorld\RimWorld_Data\Managed\0Harmony.dll</HintPath> <Private>false</Private> </Reference> </ItemGroup> </Project>

TargetFramework指定为net48,对应Rimworld使用的.NET Framework版本;如果编译报"不支持目标框架",确认IDE里装了对应开发包。OutputPath直接指向DemoMod的Assemblies,Private设为false表示不在输出目录复制游戏DLL,防止Mod发出去时把一堆几十MB的程序集打包进去。HintPath是相对路径,建议用相对路径而不是绝对路径,否则你换目录或换电脑就要改一次引用。

下一步写一个能验证"管道通关"的类。通常的做法是放一个启动类,注册一个Harmony自动补丁:

using System; using HarmonyLib; using RimWorld; using Verse; namespace DemoMod { [StaticConstructorOnStartup] public static class Startup { static Startup() { var harmony = new Harmony("com.example.demomod"); harmony.PatchAll(); } } }

这个类没有入口方法,[StaticConstructorOnStartup]告诉游戏在启动阶段执行静态构造函数。Harmony实例的ID参数是全局唯一标识,建议用"作者名.项目名"格式,同一个ID重复实例化会报错。PatchAll会自动扫描当前程序集里所有[HarmonyPatch]标注的类,后续加补丁只需要写新类,不用再手动注册。这段代码是项目源码和零散文件目录的最大区别:一切挂载都集中在一处,而不是靠每个文件自己初始化。

编译后,在Assemblies目录会生成DemoMod.dll。把Mod目录放到游戏Mods下,在游戏主菜单Mod列表里勾选,进入游戏如果没报红字、Mod列表能显示名称和版本,环境就算通关。这里有个经常被忽略的动作:改完DLL重新编译后,必须完全退出游戏再重进,游戏不会在运行中热加载新的程序集。

2.3 用About.xml完成Mod身份证

About.xml是Mod的身份证,游戏要求它必须存在,关键字段不能错。一个最小可识别版本:

<?xml version="1.0" encoding="utf-8"?> <ModMetaData> <name>DemoMod</name> <author>你的名字</author> <packageId>com.example.demomod</packageId> <supportedVersions> <li>1.5</li> </supportedVersions> <description>用于学习Rimworld Mod制作的DemoMod</description> </ModMetaData>

name显示在Mod列表里,packageId必须全局唯一,两个Mod用同一个ID会互相冲突,直接导致其中一个不加载。supportedVersions不是随便填的,填一个不存在的版本,游戏在加载时发现版本不匹配,会把这个Mod标成不兼容并跳过;这里需要替换为你游戏对应的版本号。description可以写长一点,它显示在Mod详情框里。此外,Mod列表的加载顺序由loadAfter和loadBefore控制,这个字段在正式Mod里几乎必填,因为很多Mod需要在本体加载后再执行自己的补丁。

到这里,环境搭建结束。真正的开发环境不只是IDE,而是"游戏目录 + Mod目录 + 工程文件"三者的协调,项目源码的价值在这一步已经开始体现。接下来进入源码核心:Harmony。

3. 拆解Harmony与C#源码:从补丁机制到第一个可运行逻辑

3.1 Harmony为什么是Rimworld Mod的核心

任何Rimworld Mod教程绕不开Harmony,它本质是一个运行时方法补丁库。基本原理是:在某个原版方法执行前、执行后、甚至替换整个方法时,插入你的代码。Rimworld没有官方Mod扩展接口,社区的做法就是通过Harmony给游戏方法"打补丁"。你会遇到Prefix、Postfix、Transpiler这几个概念。Prefix在原方法之前执行,适合做条件拦截;Postfix在原方法之后执行,适合修改返回值;Transpiler直接改IL中间代码,不推荐新手用,因为每次游戏更新都可能让IL失效。

看一段能直接用的补丁代码:让所有殖民者的移动速度提升10%。

using HarmonyLib; using Verse; namespace DemoMod { [HarmonyPatch(typeof(Pawn))] [HarmonyPatch(nameof(Pawn.MoveSpeed), MethodType.Getter)] public static class Patch_Pawn_MoveSpeed { static void Postfix(Pawn __instance, ref float __result) { if (__instance.IsColonist) { __result *= 1.1f; } } } }

[HarmonyPatch]标注目标类、成员名和成员类型。MoveSpeed是属性,所以MethodType.Getter表示补丁的是属性的get访问器。Postfix接收两个特殊参数:__instance表示被补丁的实例对象,ref float __result表示原方法的返回值,你可以读取并修改它。这段演示了核心思想:不改游戏文件、不破坏存档,在运行时动态改变逻辑。Harmony的补丁是叠加的,多个Mod可以同时补同一个方法,执行顺序由加载顺序决定,这也是Mod之间冲突的主要来源。

有一点需要特别说明:补丁类必须是静态类,方法必须为static。如果写成实例方法,Harmony会在应用补丁时直接跳过,且不给出明显报错。另一个新手容易踩的点是方法签名过于具体:原方法有重载时,你还需要提供参数类型数组来精确指定目标重载,否则Harmony匹配失败。

3.2 从源码角度理解Mod的初始化生命周期

一个Mod从启动到运行,源码层面的生命周期大概是:游戏加载程序集,执行[StaticConstructorOnStartup]静态构造函数,Harmony.PatchAll扫描并挂载所有补丁,随后Defs被解析成对象,游戏实例化并运行。这里面容易忽略的一个点是:在[StaticConstructorOnStartup]里访问Defs多半是不安全的,因为XML Defs的加载顺序在PatchAll之后。想在启动时读取某个ThingDef,正确做法是延迟到第一次需要访问的时机,而不是在构造函数里直接查。

我见过不少Mod作者在这里把逻辑写死,结果游戏启动随机红字,重启几次又正常,被当成"玄学"问题。实际上原因是Defs尚未加载完成。稳妥的做法是定义一个GameComponent,把Mod级状态放在它的ExposeData和Tick里,而不是塞进静态字段。静态字段虽然写着方便,但存档加载、热重载时它的生命周期完全不受控。

来看一个"延迟初始化"的推荐写法:

using Verse; namespace DemoMod { public class DemoGameComponent : GameComponent { private bool initialized; private ThingDef cachedDef; public DemoGameComponent(Game game) : base(game) { } public override void GameComponentTick() { if (!initialized) { cachedDef = DefDatabase<ThingDef>.GetNamed("DemoMod_Machete"); initialized = true; } } } }

GameComponent是Rimworld提供的一个随存档保存的游戏级组件,它的Tick方法在游戏运行期间每帧被调用,这也意味着你可以在组件里做状态管理。在这个例子里,第一次Tick时才去查DefDatabase,确保Defs已经加载完成。注册GameComponent的方式是在Mod根目录加一个<GameComponent>的Def,或者用Harmony补丁一个初始化点,我通常用后者,避免多写一个XML文件。

3.3 编译、加载与日志验证:判断补丁是否生效

补丁写完后,最直接的验证方式是看游戏日志。日志会输出Harmony的相关信息,在日志里搜你的harmony ID或者补丁类名,能看到补丁是否被标记为applied。但这类信息通常不够直观,我更推荐的做法是:在Postfix里写一行Log.Message,进入游戏触发该逻辑,然后打开日志文件搜索标记内容。如果日志里有输出,说明补丁挂载成功且逻辑执行了;如果没有,检查类名拼写、方法签名是否匹配、补丁是否被其他Mod的加载顺序影响。

日志是Mod调试唯一不撒谎的黑匣子。Rimworld把游戏内Log窗口的输出同时写入文件,路径在玩家专属目录下的Logs文件夹。找不到时,打开游戏内的Debug Actions,里面有OpenLogFolder,直接定位到日志所在位置。这一步对后续所有排查都至关重要,第5章的每一条踩坑记录几乎都要回到日志上做判断。

4. 用XML Defs定义内容:从ThingDef到配方源码联调

4.1 Defs不是配置文件,是对象初始化器

很多新手把Rimworld的XML Defs当成配置文件,这个误解会带来很多坑。实际上每个Def节点在游戏启动时会被反序列化为一个对应的C#对象,比如ThingDef节点最终变成ThingDef类的实例,节点里的defName、label、description等子节点会填充到对象的对应字段。这个机制决定了:你写XML时,节点名必须与C#类的公开字段名匹配,拼错一个字母,游戏不会报编译错误,而是在日志里抛"Field not found"或直接忽略该值。

来看一个自定义近战武器的完整Def:

<?xml version="1.0" encoding="utf-8"?> <Defs> <ThingDef ParentName="BaseWeapon_Melee"> <defName>DemoMod_Machete</defName> <label>demo砍刀</label> <description>一把用于教学的砍刀。</description> <thingClass>ThingWithComps</thingClass> <statBases> <MeleeWeapon_DamageAmount>12</MeleeWeapon_DamageAmount> <MeleeWeapon_CooldownMultiplier>1.2</MeleeWeapon_CooldownMultiplier> </statBases> <tools> <li> <label>slash</label> <capacities> <li>Cut</li> </capacities> <power>20</power> <cooldownTime>1.5</cooldownTime> </li> </tools> </ThingDef> </Defs>

ParentName表示继承哪个原版模板,BaseWeapon_Melee是原版近战武器的基类,继承它会自动获取武器的大部分基础字段;你自己定义的子节点会覆盖或补充继承来的值。thingClass决定这个对象在游戏里的行为类。statBases里的参数对应游戏里的基础属性,参数名必须与StatDef的defName一致,写错只会在日志里出现一条"Unknown StatDef"。tools节点定义武器的攻击方式,capacities里的Cut决定它能切割植物还是割伤肢体,这个字符串也必须与游戏里的DamageDef对应。

注意XML文件编码。如果有中文注释或label,保存为UTF-8带BOM或UTF-8无BOM都可能遇到读取问题,我的习惯是统一用UTF-8编码,并在文件头部不加任何多余字符。有些编辑器默认存成GBK,游戏加载后label、description变成乱码,这类问题在日志里看不出来,只能靠你看到游戏内文字异常来定位。

4.2 为自定义物品写RecipeDef和对应的C#逻辑

光有武器Def还不够,要让殖民者能制造它,还需要RecipeDef定义制作配方。RecipeDef的关键字段包括工作台类型、材料清单、产物和权限要求。下面是一份能配合原版锻造台使用的配方:

<RecipeDef> <defName>DemoMod_MakeMachete</defName> <jobString>制作demo砍刀。</jobString> <workAmount>150</workAmount> <workSpeedStat>Recipe_Speed</workSpeedStat> <ingredients> <li> <filter> <thingDefs> <li>Steel</li> </thingDefs> </filter> <count>20</count> </li> </ingredients> <products> <li>DemoMod_Machete</li> </products> <recipeUsers> <li>TableMachining</li> </recipeUsers> </RecipeDef>

workAmount是工作总量,数值越大制造越慢,它除以制作速度得到实际时间。ingredients定义材料需求,filter限定允许的材料类型,这里限定为钢。count是单次制造的消耗数量。products一定要写DefName而不是label,它必须与之前ThingDef里的defName完全一致,这个字符串不一致是最常见的翻车原因,现象是配方制造完毕时没有产出。recipeUsers是允许执行该配方的实体DefName,TableMachining对应原版机械加工台;不写这一项,殖民者可能没有任何工作台能执行这个配方。

如果你的武器需要额外逻辑,比如"挥砍时附带火焰伤害",就需要在ThingDef里加comps节点,并在C#端写一个CompTargetEffect或CompUseEffect。C#侧与XML的联调关键在字符串对应关系:XML里的compClass属性值必须是你的C#类的完整命名空间。这里最容易出的问题是:改了C#类名后忘记同步改XML,或者把命名空间写错,游戏加载Def时找不到类型,整个Def被丢弃。

4.3 用Abstract和Patch机制降低多个Mod共存时的爆炸概率

当你需要定义十种不同伤害的武器时,重复复制整段XML是灾难。Rimworld提供了Abstract和继承机制:先定义一个Abstract基类,再让具体Def继承它。实践习惯是每个Mod建一个AbstractDefs.xml,把所有共用参数下沉到基类,具体Def只写差异字段。这样项目源码更清晰,也为后续内容扩展留了位置。

另一个重要机制是Patch文件,它允许在不新建Def的情况下修改原版Def,比如把原版武器的伤害从5改成8。基本结构是Operation.Add、Operation.Replace和Operation.Remove。与直接定义Def相比,Patch方式有更好的兼容性:直接定义同名Def,后加载的Mod会覆盖先加载的;用Patch方式做细粒度修改,两个Mod各改各的字段,冲突概率显著降低。多Mod场景下,正式Mod几乎都会用Patch而不是直接覆盖原版内容。

5. Rimworld Mod避坑指南:日志与行为两级排查

5.1 日志型翻车:补丁没生效、类型加载失败、字段认不出

第一条记录,现象是补丁写完了但游戏行为没有任何变化,日志里也没有红色报错。原因通常有两个:一是[HarmonyPatch]标注的方法签名不匹配,目标方法名拼错或者参数列表不一致,Harmony静默跳过;二是harmony.PatchAll没有被调用,或者harmony ID冲突导致抛出的异常被你忽略了。解决方法是先检查Startup类是否存在且被[StaticConstructorOnStartup]标注,然后在Postfix第一行写Log.Message("patch reached"),重新编译并进游戏触发该逻辑,再查日志有没有这条输出。如果连日志都没有,问题就在挂载环节,去查方法签名。

第二条记录,现象是编译通过,运行时报TypeLoadException或者"找不到类型"。这通常是引用程序集不完整导致的,比如你的代码用了UnityEngine里的数学类型,但csproj只引用了Assembly-CSharp.dll,没引用UnityEngine核心DLL。解决方法是回到csproj,把Managed目录下的必要DLL都加进去,同时确认TargetFramework是net48。还有一个隐蔽情况:IDE的引用列表里显示DLL存在,但HintPath指向了旧版本的Managed目录,导致加载了错误的程序集。检查HintPath是否仍然指向当前游戏版本的目录。

第三条记录,现象是Defs里的字段不起作用,日志里出现Unknown StatDef或Unknown Def。原因是XML节点名与C#类的字段名不匹配,或者定义在了错误的父节点下。解决方法是打开日志,搜索完整的报错字段名,再对照反编译工具看Def类的公开属性。这里需要强调一个习惯:不要凭猜补字段,日志会明确告诉你是哪个Def的哪个字段没被识别,照着改很少需要第二次猜测。

5.2 行为型翻车:Mod被跳过、更新后变红字

第四条记录,现象是Mod列表里能看到名称,但游戏启动时Mod被标成不兼容或者直接跳过。常见原因是About.xml里的supportedVersions写得与实际游戏版本不符,或packageId与另一个Mod重复。有些Mod作者为了省事,直接复制别人的About.xml,连packageId都原样保留,这是最典型的操作。解决方法是packageId统一用你自己的反域名格式,supportedVersions按照当前版本填写;如果不确定格式,先只写一个主版本号。

第五条记录,现象是游戏更新后之前正常的Mod开始报红字,同一套源码没有改动。原因是游戏程序集API变化,原方法签名变了,Harmony补丁还按旧签名挂载,匹配失败。解决方法是重新检查被补丁方法的签名,这种情况最常出现在方法参数类型变化或返回值类型变化。我的建议是:凡是补丁了原版方法,就在源码里注释里记录"补丁目标方法名 + 所在游戏版本 + 签名"三条信息。游戏更新时,优先检查这些方法是否变动。另一个能够降低这类问题频率的做法是避免补丁到过于底层的热路径方法,优先补丁封装的业务方法,API相对稳定。

6. 让源码自己说话:验证Mod与跨版本维护的进阶习惯

Mod做完能跑,只算完成一半;另一半是"下一次游戏更新后还能不能跑"。这个习惯是从某次翻车里学来的:一个DemoMod补丁了某个原版方法,当时一切正常,过了一个月游戏更新后红字一片,反编译一看原方法加了新参数,旧补丁直接失配。从那以后,我要求自己的每个补丁都带上目标方法的签名记录,放到源码注释的第一行。

验证方法按三层来做。第一层是每次编译后用最小测试存档进游戏,只勾选自己的Mod,触发一次被修改的逻辑,看日志有没有新增输出。第二层是兼容测试,勾选几个常用的大型Mod,确认没出现加载顺序相关的红字。第三层是跨版本测试,在Mod的About.xml里明确标注支持的版本范围,而不是一份XML用到底。

进阶阶段,值得做两个事:一是把Mod拆成"核心程序集 + 内容Defs"两层,核心程序集负责Harmony补丁和GameComponent,内容Defs全部走Abstract和Patch机制。这样新增内容时不需要改C#代码,出问题也能快速定位是逻辑层还是数据层。二是习惯用反编译工具阅读游戏程序集,把它当作游戏源码来用;Rimworld的大量行为细节不在文档里,而在Assembly-CSharp.dll的IL里。

最后说一个坚持了很久的习惯:每次改完Mod,发布前强制清空一次游戏日志、重进游戏、跑一遍触发路径,确认日志里没有Unknown、Exception、Patch跳过这三类关键词。这个动作虽然机械,但能挡掉绝大多数发布后红字。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进