
鬼谷八荒的内置模组编辑器功能很丰富,但是有一些功能无法实现,所以还是需要写一部分代码,以实现例如生子等功能。
开发环境就是很经典的C#环境,直接安装Visual Studio社区版即可。
打开鬼谷八荒,新建mod,点击 代码编写 按钮,

先 打开教程目录,看一下说明书,里面会有教你如何设置鬼谷八荒的启动选项
--melonloader.showconsole
该启动选项会在开启游戏的时候同时开启 控制台,方面我们代码调试。调试代码的时候,把 调试是否创建NPC? 选项关闭了,然后点击 进入游戏调试 按钮,就可以快速新建存档,然后开始调试了。
教程目录里面还有鬼谷八荒的代码API文档,会经常用到。
然后点击代码编写窗口的 打开代码目录,就可以开始mod代码编写了。
通过Visual Studio(VS)打开代码目录的代码工程以后,我们可以看到入口代码文件ModMain,里面有一个全局对象,叫做g,在VS里面用键盘敲入g. , VS会提示g对象里面有什么字段,里面有很多东西,例如g.world, g.conf, g.log, g.cache等等,对应代码API文档(GGBH_API.chm)里面的 以Mgr 结尾的一系列对象,这一系列对象足以管理鬼谷八荒里面的大部分游戏数据了。
例如,如果你在该模组内通过模组编辑器新建了一个大地图移动触发的奇遇,那么相关奇遇就会出现在 g.world.fortuitousTrigger.trigger1.items 里面。类似地,你新建的剧情也可以通过 g.world.dramaPackage.trigger1.items 找到。你也可以写代码来通过这些接口来新建奇遇和剧情。
其实模组编辑器里面的奇遇或剧情也可以自己新建,奇遇本质上就是事件触发机制,我后面会讲。至于新建剧情,我的推荐是先通过模组编辑器新建一个剧情,然后在代码通过 剧情ID(即模组编辑器剧情编辑里面的 DramaDialogue-ID)来控制剧情的播放和相关行为,这样更灵活简便一些。VS工程里面的Example.cs有如何控制剧情的代码片段。
我们首先讲鬼谷八荒游戏内置的事件触发机制。打开代码API文档,搜索EType,里面有各种各样的事件触发机制,里面的类方法的作用就是你给他参数,然后生成对应的事件的 StringId,通过该 StringId 就可以绑定相应的事件触发。例如如下代码绑定了剧情打开时的事件
g.events.On(EGameType.OneOpenDrama(21311421412), (Il2CppSystem.Action<ETypeData>)DramaTrigger);
其中,21311421412 是剧情ID,DramaTrigger 是你针对这个剧情打开事件的回调处理函数,由你新建作为 ModMain对象 的类方法。可以看到,这个函数会接收 ETypeData 作为输入,这个 ETypeData 就是鬼谷八荒游戏触发该事件时,会传给回调处理函数的当前触发事件上下文,在这个例子中会包含剧情相关的数据,可以把该参数打印至控制台
public void DramaTrigger(ETypeData data) {Console.WriteLine(data.GetType());}
我建议,所有的事件绑定都放在 IntoWorld 事件触发之后,即
public void Init()
{
/* some code omitted */
g.events.On(EGameType.IntoWorld, (Il2CppSystem.Action<ETypeData>)InitTrigger);
}
public void InitTrigger(ETypeData data)
{
Il2CppSystem.Action<ETypeData> callonMoveTrigger = (Il2CppSystem.Action<ETypeData>)MoveTrigger;
var mvkk = new UnitActionMovePlayer(Vector2Int.right);
g.events.On(EGameType.OneUnitCreateOneActionBack(g.world.playerUnit, mvkk.GetIl2CppType()), callonMoveTrigger);
Il2CppSystem.Action<ETypeData> callonTestTrigger = (Il2CppSystem.Action<ETypeData>)TestTrigger;
var llz = new UnitActionLuckAdd(10);
g.events.On(EGameType.OneUnitCreateOneActionBack(g.world.playerUnit, llz.GetIl2CppType()), callonTestTrigger);
/* 打印事件对应的 StringID */
foreach (var u in g.world.unit.allUnits.ToArray())
{
Console.WriteLine("事件StringID: " + EGameType.OneUnitCreateOneActionBack(u.Value, llz.GetIl2CppType()));
Console.WriteLine("事件StringID: " + EGameType.OneUnitCreateOneActionBack(u.Value, mvkk.GetIl2CppType()));
}
}
这是因为,例如 OneUnitCreateOneActionBack 这样的事件需要参数,而 WorldUnitBase 类的参数只有当 IntoWorld 事件触发之后才会被游戏初始化,否则就是空指针,会导致事件绑定出错。值得注意的是,对于 OneUnitCreateOneActionBack 这样的事件,只有 Unit 参数(如 g.world.playerUnit,玩家自己)会被编码进事件对应的 StringID,而诸如 UnitActionMovePlayer 和 UnitActionLuckAdd 的参数则不会被编码进 StringID,具体情况请自己打印出来看看就知道了。然后,通过编写相应的 事件回调处理函数 就可以实现特定的 MOD 功能了。
更具体的事件机制,建议查看 g.events.allEvents 对象,里面包含了很多游戏默认的事件触发机制, allEvents 对象的数据类型是 ConcurrentDictionary<String, List<EventsMgr.EventsData>>,这说明一个 事件StringID 往往会绑定一个 List<EventsMgr.EventsData> 的时间回调处理函数,建议你看看代码API文档里关于 EventsData 的类定义,就会明白所谓的 g.events.On/Off 其实就是往这个 List 里添加/删除 回调处理 机制。在事件 StringID 被游戏本体触发的时候,这个 List 里面的回调都会被执行,游戏本体会把事件触发的上下文通过 ETypeData 传给 回调函数,由 回调函数 自己判断该事件触发是不是就是自己想要的,例如 UnitActionLuckAdd 到底是不是添加的是自己想要的 人物气运,然后相对应的执行相应的操作。
回调函数的编写我后面再讲。
我不建议用模组编辑器新建奇遇,因为奇遇就是事件触发机制而已,但是我建议用模组编辑器新建剧情,因为这可以大大简化我们的回调处理函数。具体我后面会讲。
另一种方法是自制事件触发,我们都注意到 ModMain 类默认绑定了一个 OnUpdate 方法,即
corUpdate = g.timer.Frame(new Action(OnUpdate), 30, true);
其中参数 30 表示游戏每 30 帧触发一次,这就给我们自定义事件触发的机会,例如
private void OnUpdate()
{
try
{
if (player_initialized == false)
{
Console.WriteLine(g.world.playerUnit.data.unitData.pointX);
pX = g.world.playerUnit.data.unitData.pointX;
pY = g.world.playerUnit.data.unitData.pointY;
player_initialized = true;
}
else
{
if (pX != g.world.playerUnit.data.unitData.pointX || pY != g.world.playerUnit.data.unitData.pointY)
{
pX = g.world.playerUnit.data.unitData.pointX;
pY = g.world.playerUnit.data.unitData.pointY;
// 发送 玩家单位移动事件
g.events.Emit("CustomPlayerOnMove");
}
}
}
catch { player_initialized = false; pX = 0; pY = 0; }
}
那么,我们就可以通过
g.events.On("CustomPlayerOnMove", (Il2CppSystem.Action<ETypeData>)onPlayerMoveHandler);
来绑定使用这个 自定义的玩家单位移动事件 了。更加复杂的事件机制也可以写,就看你自己需要了。
自己写事件机制的好处是可以控制多久我们检测一次事件(每30帧还是60帧),不必担心 事件机制 过于影响游戏性能。
在下面的例子中,我就介绍一下生子功能如何实现,
private void onPlayerMoveHandler(ETypeData e) // 由我们自定义的玩家移动事件触发
{
var modUsed = g.world.playerUnit.data.unitData.objData.ContainsKey("used_bornmod");
if (modUsed)
{
Console.WriteLine("MOD已经使用过"); // 如果有需要可以让该 mod 只触发一次
}
if (getrandom.Next(0, 10000) < 3000) // 用随机数控制触发概率
{
bool created = false;
try
{
// 如果失败,DramaFunctionTool 会导致整个事件回调终结,所以使用 try catch
DramaFunctionTool.OptionsFunction("createNPC_1_2"); // 创建一个精英练气人物,可从 modFAQ 中的 配置对照表 找到该 API
created = true;
}
catch { created = false; Console.WriteLine("Create NPC failed!"); }
if (created == true)
{
UICustomDramaDyn dramaDyn = new UICustomDramaDyn(744646348); // 打开生子剧情 DramaDialogue-ID 744646348,可从模组编辑器找到
//点击按钮回调
dramaBorn = () => {
int indexnum = 0;
int distUnit = 100;
WorldUnitBase selOne = null;
// 寻找新建的NPC,与玩家距离最近,且是最新的角色(即indexnum最大)
foreach (var akk in g.world.unit.fastAreaUnit.GetUnitRange(g.world.playerUnit.data.unitData.GetPoint(), 3))
{
if (akk.isDie == false && akk.data.unitData.propertyData.gradeID == 1) // 如果没有死亡,且是练气期
{
int xpa = akk.data.unitData.pointX - g.world.playerUnit.data.unitData.pointX;
int ypa = akk.data.unitData.pointY - g.world.playerUnit.data.unitData.pointY;
if (akk.data.unitData.indexNum > indexnum && (xpa * xpa + ypa * ypa) < distUnit)
{
distUnit = xpa * xpa + ypa * ypa;
indexnum = akk.data.unitData.indexNum;
selOne = akk;
}
}
}
if (selOne != null) // quick path
{
// 设置新建NPC为玩家后代
g.world.playerUnit.data.unitData.relationData.children.Add(selOne.data.unitData.unitID);
selOne.data.unitData.relationData.parent[0] = g.world.playerUnit.data.unitData.unitID;
// 设置新建NPC的名字、魅力、性格、年龄等等
selOne.data.unitData.propertyData.name = new String[] { g.world.playerUnit.data.unitData.propertyData.name[0], selOne.data.unitData.propertyData.name[1] };
selOne.data.unitData.propertyData.beauty = 900; // 仙姿
selOne.data.unitData.propertyData.inTrait = g.world.playerUnit.data.unitData.propertyData.inTrait; // 继承性格
selOne.data.unitData.propertyData.outTrait1 = g.world.playerUnit.data.unitData.propertyData.outTrait1;
selOne.data.unitData.propertyData.outTrait2 = g.world.playerUnit.data.unitData.propertyData.outTrait2;
selOne.data.unitData.propertyData.age = 16 * 12; // 16岁
}
else
{
Console.WriteLine("Cannot Find New Created NPC!");
}
};
dramaDyn.SetOptionCall(1463576435, dramaBorn); // 1463576435 是剧情选项按钮的 ID (DramaOptions-ID),可从模组编辑器找到
dramaDyn.OpenUI(); // 打开剧情
g.world.playerUnit.data.unitData.objData.SetString("used_bornmod", 1); // 记录模组使用情况
}
}
}
如果有需要,你甚至可以通过修改 selOne.data.unitData 来改变新建的NPC的属性数据,男变女,跨性别,甚至女装大佬都是有可能的。值得注意的是,在上面的代码中,是每次玩家移动都有可能生一个,你可以自己加条件限制繁殖速度。类似地,生孙子孙女也很容易实现。
在该回调函数中,我并没有重新写一个剧情,而是通过模组编辑器新建了一个剧情,使用了该剧情的 DramaDialogue-ID 和 DramaOptions-ID,剧情的文本和按钮的文本由模组编辑器来弄挺方便的,可以解决大量的代码。当然,通过代码也可以重定义剧情的人物,详见代码工程里面默认自带的 Example.cs.
完成这一切,直接用VS生成解决方案,然后进入鬼谷八荒游戏本体开始模组调试即可。