在本篇文章中会讨论如何让自己的游戏支持Mod,以及一个良好的Mod框架模型
Contents
ModAPI
第三方与自主开发
很多游戏原版是并不支持Mod的,都是第三方制作一套ModAPI并开放给开发者,第三方的ModAPI一般先对游戏进行反编译,在游戏中“打洞”,在关键位置进行hook(调用自己的代码),然后我们可以通过这套API间接的修改游戏内容和行为。
Mod是一种自由度较高的组件,类似插件,我们可能为了让游戏更有灵活性,或者后续开发扩展以及DLC的便利,需要让游戏系统支持Mod,为了让Mod更自由的组合,在加载上Mod应该处于被动状态,而我们游戏处于主动状态。
版本号
每个ModAPI都需要有一个版本号,可以用来校验Mod是否可以正确的运行在该游戏上。
如果修改了ModAPI,那么Mod是肯定要求重新编译的,这就无形增加了Mod开发者的成本,所以我们应尽量不要修改ModAPI,将ModAPI的粒度降低,分成多个dll分发。
Mod加载
配置文件
游戏需要知道Mod的一些信息,如Mod唯一标识、Mod名字、作者、版本号等内容,所以需要一个配置文件来储存。
常见的配置文件结构有:json、json5、xml、yml。
其实很多人是很喜欢用json的,因为简单快捷,但对于需要手动配置的文件来说它不可以加注释,并且可以添加注释的json5在各语言的支持也较为有限,所以这里不推荐json。
yml其实是一个不错的配置文件选择,但它对空格的要求过于严格了,如果是小白使用yml很容易踩坑里。
推荐xml的理由:缩进格式友好,可以添加注释,并且可以使用xsd来校验配置文件的正确性,提前做好配置规则的检查。
这里放一个我的配置结构
public class ModScriptInfo { public string ScriptName { get; set; } public string Class { get; set; } } public class ModInfo { //unique ident public string Id { get; set; } //分类,如果为空则分到Other中 public string Category { get; set; } public string Name { get; set; } public string Description { get; set; } public string URL { get; set; } public ModScriptInfo Script { get; set; } public string Version { get; set; } public string Author { get; set; } public int Priority { get; set; } public string[] Dependency { get; set; } }
如何加载
考虑到Mod有撞名字的可能性,应使用了ModId来作为该mod得到唯一标识,你可以像java一样的命名com.imxqy.modid或者C#风格的命令JxCode.ModId都可以,无论如何,该字段应始终唯一。
用Unity3d的PC端来举例,Unity3d使用C#作为脚本语言,而C#是可以使用反射来主动加载dll的,我们可以利用这个特性来实现Mod的脚本编辑工作。而mod制作者只需要引用游戏的dll即可进行开发,并不需要安装unity3d,减少软件学习成本。
如果是Unreal这种C++引擎,那么就稍微麻烦了一些,你需要准备一门可以让开发者们更容易使用的语言,比如Lua。Lua在游戏开发中是非常常见的,并且C++对lua的支持也是非常完美,在github上可以找到一些虚幻的lua绑定库,很轻松就可以将Lua绑定至引擎中并使用,这里推荐使用unlua。
生命周期与加载的可逆性
在设计Mod系统时我们要思考一个问题:Mod在加载后会不会对游戏产生不可逆的影响。
像我为游戏增加了更方便的导航地图,该mod并不会覆盖原有的游戏逻辑,是可以随时加载与卸载的。
但是比如Mod拦截了原版游戏中一个物体的注册,或者游戏启动流程,那这种Mod加载就会对游戏产生不可逆,卸载掉该mod游戏会变得不正确,除非游戏重启的同时不去加载这个Mod。
所以要加载哪些Mod应该是一开始就决定好的,这也是为什么Mod自由度越高的游戏越应该用启动器,而不是在游戏内选择Mod的加载与卸载。
优先级
为了增加复用性,我们应该允许一个Mod是构建在另一个Mod之上的。如开发了一个点数系统的Mod,一些其他Mod可以使用点数系统Mod作为前置,但这样就会产生另外一个问题:Mod的生命周期与优先级。
博主认为Mod应该是有严格的加载顺序的,给与Mod自由度时难免会有拦截一些行为的操作,一些前置类库型Mod优先级最高,之后在去加载普通的Mod,最后在去加载统计类型的Mod。该加载顺序可以使用配置文件中的Priority字段控制。
事件系统
事件广播
博主这里的事件系统设计的结构为EventChannel与EventBus。
实际由EventChannel承载事件发送的任务,而EventBus可以直接向所有的EventChannel广播事件,这样可以划分出多个广播单元,EventChannel就像个局域网,而EventBus是广域网。
这样做的好处有很多,我们可以在原版游戏设计中使用事件系统,并且可以不和Mod的事件混在一起。如一些允许被Mod监听的事件,只需发送到EventBus即可。
订阅者
为了语言的兼容性,我们应设计最基本的订阅方式:使用函数订阅和反订阅,如
(C#)
ModManager.Event.Subscribe("GamePlay.Player.Events.OnDeathArgs", this.OnDeath);
(Lua)
self.onDeath = function(sender, e) self:OnDeath(sender, e) end ModManager.Event:Subscribe("GamePlay.Player.Events.OnDeathArgs", self.onDeath)
除此之外,我们可以在一些语言上实现更加简便的订阅方式,如使用C#的Attribute特性。
using GamePlay.Player.Events; [EventHandler] private void OnDeath(object sender, OnDeathArgs e) {}
通过反射将第二个形参类型的完全路径当做eventId执行原来的订阅函数Subscribe,保证了通用性的订阅方式和利用语言特性简便的订阅方式。用此种方式来批量订阅。
优先级
事件也是需要优先级的,如果有Mod在一个事件中修改了游戏的行为,那么后面收到事件的Mod的行为与在此之前的不一致,如果我们需要做一个统计Mod,那么它的优先级应总是最低的。
可取消的事件
如果事件是不可取消的,那么我们可能需要编写更多的事件,如death事件之前需要有个requestDeath事件,好让一些Mod了解玩家要死了,在requestDeath里Mod可以返回false,使其不去触发death事件。
但这么做需要写的代码实在太多了,我们需要更简便的写法,我们可以允许一个事件被标记为Cancellable。
[Cancellable] public class OnDeathArgs
在订阅者收到Death消息时,可以使用e.Cancel = true来取消该事件。
但即便如此这种方式也收到了很大的限制,比如在quitting和requestQuit上就必须分为两个事件来处理,如果仅有一个可以取消的quitting事件,前面的事件处理已经做好卸载工作了,后面的事件处理却取消了退出,这时的游戏就很难从半卸载的状态中恢复过来。虽然可以和优先级来一起解决这个问题,不过这么做系统的复杂度会上升很多,对于各种三方组合来说的容错率更低,我们要在关键位置写更多的事件来处理这些问题。
Mod支持与通信
依赖
在上述「Mod加载->优先级」小节中介绍了一些关于Mod加载顺序的问题。
一个前置被依赖的Mod理应要比依赖更早的加载
在配置文件中的Dependency可以用来检查游戏前置Mod环境是否齐全,该Mod是否可以正确的运行。
支持
Mod不光可以和原作发生化学反应,和Mod之间也一样可以。
我们制作Mod时甚至可以增加对其他Mod的额外支持,但这种额外支持并不是必须的。
如现在编写一个交易Mod,我们可以在加载时检测点数Mod有没有被加载,如果被加载,我们会动态的增加交易系统对点数的支持。
因为这种支持是“软”的,并没有去硬引用前置,所以我们需要以更动态的方式让Mod之间通信。
消息
一个非常常用的通信手段,通过SendMessage来指定一个目标,然后携带一些数据发送过去,接收者可以根据消息形式以及内容是否要回应。
伪代码:
var mod = ModManager.GetModObject("PlayerPoint"); var point = mod.SendMessage<int>("GetPoint", this.playerId);
事件通讯
除了接受原版的事件,我们也可以在Mod自己建立事件管道,比如让所有Mod都知道玩家点数被更改了。
ModManager.EventChannel.Broadcast("PlayerPointEvent", new PlayerPointEvent(this.playerPoint))
数据储存与持久化
数据储存
越通用的东西越自由,什么是通用,什么叫做不通用:
- 字符串,整形,浮点,布尔叫做通用
- ItemDataObject,PotionDataObject等叫做不通用
博主很喜欢通用的东西,但有时通用的会稍微麻烦一点,所以一般都会先实现一套通用的,然后在这套通用基础之上根据语言封装一套便捷专用的。
一般游戏里的Entity也都是携带一些数据的,存档就是将这些Entity的状态和世界的状态序列化保存起来。
储存结构是一个key/value的结构,key是一个string,而value则是通用类型,如:
World.SpawnPoint.x = 3.0 World.SpawnPoint.y = 0.0 World.SpawnPoint.z = 3.0
可以看出World.SpawnPoint明显是一个Vector3类型,我们既可以使用通用方法
float x = data.GetFloat("World.SpawnPoint.x");
来获取,或使用C#反射反序列化实现
Vector3 p = data.GetObject<Vector3>("World.SpawnPoint");
因为这些字符串,整形,浮点,布尔的通用类型是所有语言都拥有的,可以很容易的做到跨语言,并且在不同的语言下实现GetObject或SetObject的快捷方案。
序列化
序列化应采用访问者模式。
就像是序列化系统拿着容器,交给房子里的人,房子里的人把需要记录的数据放到容器后在还给序列化系统,序列化系统在前往下一家。
public class PlayerEntity { public override void Serialize(SerializeStream s) { s.data.SetObject("Entity." + Name + ".State", State); } }
调用时
SerializeStream ser = GetSerializer(); for(var entity in entities) entity.Serialize(ser);
收集全部信息后,我们就可以把数据写出到文件中了~
游戏内容注册式
在游戏中所有道具物品等内容,都应该使用注册来放进游戏内容中,这样加大了Mod脚本对于道具的控制能力。
同时在注册游戏对象时广播该事件,可以让订阅者来拦截注册或修改注册的内容。
同样的,注册式是由脚本控制,在不同的状态下可以更加灵活。比如,我发现了一个支持的Mod加载了,我可以把一些支持扩展内容注册进去。
资源定位
资源重定向
应允许游戏资源管理在读取时对路径进行重定向,这是什么意思呢。
我加载一个桌子,这个桌子的路径为Models/Table,那么我可以在我的Mod中同样添加一个桌子的模型,并且向游戏的资源管理系统注册一个重定向,这样加载到Models/Table时,可以从你Mod的里获取模型,从而达到模型替换的目的。
ModManager.res.Redirect("Models/Table", this.OnLoadTable);
其他的贴图音频等资产以此类推。
程序生命周期
- 程序和框架初始化
- 加载并初始化所有Mods(依赖检查,构造对象等工作)
- #后续所有行为都可被Mod修改
- 加载游戏内容
- 加载Mods游戏内容
- 进入游戏