原文载于知乎专栏《【真正的和平模式】二、任务系统的实现》,作者为 Viola-Siemens,
原地址:https://zhuanlan.zhihu.com/p/644603030
【真正的和平模式】二、任务系统的实现
我像迷途小鹿 得不到救赎
才会在此后的路 忽视了所有景物
我眼里的天空变得荒芜
连诗里的飞鸟也迷了路
我像迷途小鹿 得不到救赎
才会将所有心迹 毫无保留呈现出
你纵身跃进了满天大雾
我找不到你也忘了归途
——《迷途小鹿》(歌手:葛雨晴,作词:峦无眠)
偶然间听到的歌曲,主题稍微契合就写进来了(逃
背景
正如前文所述,我们的模组要实现一个任务系统,记录着每个玩家完成任务的进度,以及触发任务条件等等。
为了方便数据包作者和其它模组作者的扩展,我决定使用数据包形式,单向链表结构来添加任务。第一步就是确定数据格式——在这里我选择使用如下格式:
1 | { |
没错,任务强制只在开始和结束时才触发对话,而对话的目标最多只有一位NPC(当然也可以是玩家独白)。这是这个简易系统唯一的局限性。
至于触发,后面再讲。这里提前简要说一下,对于任务开始和结束,原生模组提供了两种任务触发方式:其一是summon_block,数据包作者们可以直接使用;其二是api.MissionHelper#triggerMissionForPlayers和api.MissionHelper#triggerMissionForPlayer,只有衍生和联动模组开发者们可以使用。
接下来就要想如何实现了。之前做独立游戏的时候实现过任务系统,不过跟Minecraft的情况相去较远,至少没办法直接搬。所以我直接重新造一个轮子。
不过做模组,很重要的一点就是,想想原版有什么类似的功能,那么只需要轻松照抄,稍加修改即可。
我最先想到的则是原版的进度系统——数据包作者们可以在data/
ServerAdvancementManager详解
打开net.minecraft.server.ServerAdvancementManager文件,我们可以看到原版进度系统的实现:
1 | public class ServerAdvancementManager extends SimpleJsonResourceReloadListener { |
不太重要的部分已经略去,对于这部分我们逐一解读。
1. MissionManager的实现
首先它继承了SimpleJsonResourceReloadListener类,这个类原版有两种Manager继承了它,其一是进度系统,其二是合成系统;而Forge也定义了LootModifierManager,用以实现战利品表的更改。这个父类的功能很简单,可以实现json格式的数据读取和自动加载,只需重写apply函数即可。
也许有写过低版本模组的同仁们就要问了,战利品表系统不也继承了它吗?不错,曾经是,不过1.20这部分被大幅修改了,如今LootDataManager仅仅是实现了SimpleJsonResourceReloadListener的爷爷接口PreparableReloadListener。
回归正题,apply函数传了三个参数,分别是所有JSON文件内容(按id索引在map中)、Resource Manager和Profiler Filler。事实上我们实现自己的需求也无需后两个参数,只要写好读取json文件的处理逻辑即可。
其次,构造函数传递了两个参数,一个是编码JSON文件的方法,一个是扫描文件目录。对于进度系统则是”advancements”,如果我希望任务系统的扫描目录是data/
于是我们便可以实现任务系统:
1 | public class MissionManager extends SimpleJsonResourceReloadListener { |
然后,如何将这个监听器真正监听在资源加载阶段呢?当然你可以mixin,但Forge是有这个API的,所以我优先去调用这个API:
1 | public class ForgeEventHandler { |
并在主类中,通过Forge bus中注册它。
1 | MinecraftForge.EVENT_BUS.register(new ForgeEventHandler()); |
接着,我们有了总领的任务系统,进一步的,如何去维护每个人的任务的进度?于是我们发现了PlayerAdvancements类。
2. PlayerMissions的实现
首先,我们可以看到在PlayerList中有一个维护每个人进度完成情况的成员:
1 | private final Map<UUID, PlayerAdvancements> advancements = Maps.newHashMap(); |
而在ServerPlayer中也有自身独立的PlayerAdvancements:
1 | private final PlayerAdvancements advancements; |
当然,这个advancements只是PlayerList中对应的那个PlayerAdvancements的一个影子。
参考这个类,我们可以实现自己的PlayerMissions。它需要包含玩家完成过的任务、玩家正在进行的任务(其余都是还未接收的任务):
1 | public record PlayerMissions(Path playerSavePath, ServerPlayer player, List<ResourceLocation> activeMissions, List<ResourceLocation> finishedMissions) { |
为了安全性,在这里做了检查,允许玩家接收的任务,玩家必须已经完成过所有前置任务。
那么,如何将它加进PlayerList里呢?其实未必要加进PlayerList中,你也可以自己写一个SavedData来实现,不过这次的mixin没有副作用,而且更符合直觉架构,因此我选择了mixin:
1 |
|
这里抽象了一个IPlayerListWithMissions接口,作用是,由于Mixin类无法被实例化或强制转化,所以要想调用getPlayerMissions函数,必须通过一个接口来访问。比如:
1 | ((IPlayerListWithMissions) serverLevel.getServer().getPlayerList()).getPlayerMissions(player) |
没办法,都mixin了,还在意啥代码美观(划掉)。
那么如何将它进行序列化呢?我们又要mixin进ServerPlayer类,注入读写nbt和restoreFrom方法:
1 |
|
这里的IMonsterHero接口也是同样,方便其它部分调用,判断玩家是否已经实现了某个怪物的全部委托。
一定不要忘记restoreFrom函数!否则玩家不论是从末地返回主世界,还是死亡后重生,这些信息都会消失!
至于为什么不restore playerMissions,别忘了它只是个影子,之前我也是restore了,debug过程中发现了这个问题,于是把它删掉了。
那么任务系统算是成功实现了,不过还需要客户端的UI,显示任务对话,如题图所示。该怎么实现呢?
Menu+Screen的两层架构
首先介绍一下Minecraft的UI架构。一般的功能性UI都是两层结构:第一层是Menu,位于服务端(客户端会同步它),便于与世界交互,如玩家放入熔炉一根烈焰棒(真有人这么富吗?);第二层是Screen,位于客户端,执行显示界面,处理玩家请求的功能,如显示熔炉UI,显示燃料剩余量、烧炼的进度等等。有些UI由于无需与服务端部分交互,便只有Screen没有Menu,比如玩家进度、统计界面等,只需一次发包后便可显示。
而我们的需求是,首先,任务界面打开过程中,怪物不能攻击玩家——这就限制了我们的实现,Menu部分必须要存在;其次,玩家客户端要显示对话,这部分是由服务端的MissionManager.Mission发包过来的;最后,对话结束后要提示玩家接收到了新的任务或完成了任务,这又是服务端向客户端发送的。
那么我们可以做如下设计:
- Menu部分维护了该任务的所有对话。
- Screen显示了对话内容和讲话的生物,玩家可以按下按钮来向前/向后阅读。
Menu的实现
于是我们可以实现如下Menu:
1 | public class MissionMessageMenu extends AbstractContainerMenu { |
第一个构造函数用于RPMMenuTypes中的注册:
1 | private static final DeferredRegister<MenuType<?>> REGISTER = DeferredRegister.create(ForgeRegistries.MENU_TYPES, MODID); |
第二个构造函数则是用于在接收/完成任务时玩家openMenu的传参。
由于无需物品栏和槽位的操作,quickMoveStack可直接返回空;stillValid也可以随便写写了,这里是根据与NPC的距离判断的。
mission的getter和setter则是用于服务端向客户端的发包:
1 | public class ClientboundMissionMessagePacket implements IRPMPacket { |
这一步将服务端的任务内容传给了客户端,并进行了验证。
注册发包则是在主类中完成:
1 | public static final String VERSION = ModList.get().getModFileById(MODID).versionString(); |
Screen的实现
既然Menu实现好了,那Screen不就简单了吗?
1 |
|
注意高版本的PoseStack被UI系统弃用了,改用GuiGraphics做渲染,个人感觉更加方便了。
renderBg函数实现了背景渲染,以及界面右下方对话实体的显示。renderButtons显示了向前向后两个按钮的渲染,而处理玩家请求则是在mouseClicked(按下按钮,改变按钮颜色)和mouseReleased(释放按钮,执行按钮功能)中实现。而对话文本的分行是在loadCachedText函数中实现。
具体blit的数值取决于GUI资源图片的排版,由于我将按钮元素排版在下方,所以便从下方截取图像并贴在对应位置(详见仓库中GUI资源文件)。
实现了核心功能,也许玩家会想查看自己的任务完成情况和任务描述。接下来介绍显示客户端玩家任务这部分的实现方法。
显示玩家任务完成情况
首先我添加了绑定按键(默认M键),按下按键后可显示任务屏幕GUI。
1 |
|
客户端也要监听玩家按键的事件,处理打开窗口的请求:
1 | .EventBusSubscriber(modid = MODID, bus = Mod.EventBusSubscriber.Bus.FORGE, value = Dist.CLIENT) |
这里这里通过客户端和服务端之间互相发包的方式,来获取玩家任务列表(包括已完成和进行中):
1 | public class GetMissionsPacket implements IRPMPacket { |
发包注册方式和前文相似:
1 | private void setup(final FMLCommonSetupEvent event) { |
显示任务列表的Screen无需与世界交互,所以只实现一个Screen即可,无需Menu:
1 | public class MissionListScreen extends Screen { |
这里UI右上方有一个按钮,用来决定客户端玩家是否查看自己已完成的任务。这是唯一需要额外接收请求的部分。对于已完成与否的任务,要显示不同的提示,由于比较简单,不做额外讲解了。
总结
这样,我们成功地把整个任务与对话系统搬到了Minecraft中,而且支持数据包作者和拓展模组开发者们添加任务,可以说实现地非常完美了。下一部分打算讲轻松点的,主要是模组世界生成中添加结构的方法。