这是我临时添加的一节,我也是纠结了好久,决定是否要写这一节。
自定义进度有两个办法:
- 使用 NMS 提供的功能(呕——)
- 使用 Bukkit 的「Unsafe」方法
虽然按照我的准则,我很少使用 @Deprecated
的方法,但相比之下,我更不想写反射代码(笑)!
出人意料的是,进度并不是 Bukkit API 的一部分。它实际上更接近于「运行在服务端的数据包」。
因此,我希望你能看看 Wiki 上有关进度的内容。
好,如果你看完了,那么我们回来。
下面我们以创建「咏 e 之歌」进度,演示进度的创建方法。
要创建一个新的进度,首先我们要写好进度的 JSON 文件。
右键 src
,「New」、「File」,文件名填写 advancement.json
。实际上这里使用任意名字都是可以的,我们利用的是自定义配置文件。虽然配置文件都是 YAML 格式,但 Bukkit 具有保存和读取的功能,这是对任何文件都可以使用的。
接下来我们来编写:
{
// "parent": "helloworld:root"
"display": {
"icon": {
"item": "minecraft:ender_eye" // 显示图标,可以使用一个物品
},
"title": {
"text": "咏 e 之歌", // 文本
"color": "white", // 颜色
// 还有很多可设置,查 Wiki!
},
"description": "累计在聊天栏发送 91.35 万个 e", // 如果不需要进行样式设置,可以直接写字符串
"frame": "challenge", // 可选 task、goal、challenge,分别是进度、目标、挑战
"show_toast": true, // 是否右上角弹窗提示
"announce_to_chat": true, // 是否聊天栏提示「完成了进度」
"hidden": false, // 完成之前是否可见
"background": "minecraft:textures/gui/advancements/backgrounds/stone.png"
// 如果没有 parent(是根进度)则需要,否则不填
// 表示进度的背景图,可选的内容很有限:
// minecraft:textures/gui/advancements/backgrounds/adventure.png
// minecraft:textures/gui/advancements/backgrounds/end.png
// minecraft:textures/gui/advancements/backgrounds/husbandry.png
// minecraft:textures/gui/advancements/backgrounds/nether.png
// minecraft:textures/gui/advancements/backgrounds/stone.png
},
"criteria": { // 触发器,建议不要使用原版判定,保持以下内容即可
"imp": {
"trigger": "minecraft:impossible"
}
},
"requirements": [
[
"imp"
]
]
}
这里有很多要说明的。
首先是 parent
键,如果你的进度具有父节点,就需要填写这一项。反之,如果你的进度是根节点,就不需要。另外,如果该进度是根节点,那么 Minecraft 会自动在「进度」页面中用它的图标创建一个新的页面。
parent
的命名很有趣,它遵循:
<插件名称小写>:<注册时用的名字>
实际上就是 NamespacedKey
序列化后的结果。注意这里是插件名的小写,如果你的插件叫「HelloWorld」,这里就得写helloworld
。
注册时用的名字(NamespacedKey
的第二个参数)也得是小写,下面我们就会看到。
display
键中包含了所有的显示内容。
icon
是图标,目前似乎仅能够通过物品来获取,命名方式遵循minecraft:<物品的 ID>
,其中物品的 ID 你可以在 Wiki 查到。title
是标题,使用的就是 原始 JSON 文本,只不过这里不能使用 Java 代码,而要使用 JSON,你可以参考 Wiki 中的内容来了解如何使用 JSON 做到同样的效果,也就是说,这里也可以设置「点击事件」之类的。description
是描述,同样可以使用原始 JSON 文本,如果不想使用,就直接提供字符串吧。frame
是框架, 只有task
、goal
和challenge
三个选择,决定了该进度在进度窗口的显示形状以及完成时的提示文本。show_toast
是否右上角弹窗提示「进度已达成」。announce_to_chat
是否在聊天栏告知所有玩家「ThatRarityEG 达成了进度 XXX」之类的内容。hidden
在完成前是否可见。background
仅用于根进度(没有parent
的)。设置该页面的背景,可选的值已经列在上面的 JSON 中了。
criteria
及之后的部分我不建议修改,该部分用于使用 JSON 判断成就什么时候触发,但我们有事件处理器,可以通过 CLI 触发,因此不需要这项功能。
接下来我们就要考虑如何将其读入 Bukkit。
回到插件主类中来。
首先我们需要让 Bukkit 保存(解压)刚刚的 advancement.json
,写在 onEnable
方法中:
saveResource("advancement.json", false);
然后我们需要读取它。由于这里没有现成的方法,我们只能使用 Java 的内置 IO 解决方案:
String advancementJSON; // 最终产物,先占个位置
StringBuilder s = new StringBuilder(); // 高速修改时,StringBuilder 更快
try { // IO 操作可能会出错
BufferedReader reader = new BufferedReader(new FileReader(f));
// 创建读取器,文件的内容先流向 FileReader,再流向 BufferedReader
String temp; // 临时存储,用于读取一行
while ((temp = reader.readLine()) != null) {
// readLine 方法用于读取下一行,当读到文件末尾会返回 null,while 后的括号中一步完成了读取、赋值和判断 null
s.append(temp); // 添加到 StringBuilder 中
}
reader.close(); // 读取完成后关闭连接
} catch (IOException e) {
e.printStackTrace();
}
advancementJSON = s.toString();
上面的代码不难,如果无法理解也没关系,总之,这些代码运行后,advancement.json
中的内容已经被读取到了 advancementJSON
这个 String
变量中。
接下来考虑如何注册它。
Bukkit.getUnsafe().loadAdvancement(
new NamespacedKey(<插件主类名>.instance, "eee_advancement"),
advancementJSON
);
就是这样。也正是因为使用了 getUnsafe
,这里被 IDEA 用一条横线划掉了。
IDEA 很聪明,它知道这个方法被 Bukkit 设为了 @Deprecated
(不建议使用),没办法,就算是 @Deprecated
也只能用了。
当然,不使用 Bukkit 创建进度的方法也是有的,但实现起来超级麻烦(反射无易事),我们就不介绍了。
loadAdvancement
接受两个参数,一个 NamespacedKey
,一个 String
,这里的 NamespacedKey
仍然表示一个独特的 ID。建议 new NamespacedKey(...)
的第二个参数用小写,否则可能会出现未知的错误。
如果你的插件名叫「HelloWorld」,进度名叫 eee_advancement
,那么如果你需要将其指定为 parent
,就需要使用 helloworld:eee_advancement
(插件名小写 + 进度名)。
注意这里是「插件名」(plugin.yml
中的 name
),不是「插件主类名」!
String
就是刚刚读进来的 advancementJSON
。
需要注意的是,如果 advancement.json
读取失败,这个方法可能会出错,因此我们需要将它包裹在 try
和 catch
中,完整的 onEnable
方法如下:
instance = this;
saveResource("advancement.json", false); // 保存
File f = new File(getDataFolder(), "advancement.json");
String advancementJSON;
StringBuilder s = new StringBuilder();
try {
BufferedReader reader = new BufferedReader(new FileReader(f));
String temp;
while ((temp = reader.readLine()) != null) {
s.append(temp);
}
reader.close();
} catch (IOException e) {
e.printStackTrace();
}
advancementJSON = s.toString(); // 读取
try {
Bukkit.getUnsafe().loadAdvancement(
new NamespacedKey(this, "eee_advancement"),
// 这里不用 <插件主类名>.instance 是因为我们就在 onEnable 方法中,this 就是插件实例,没必要多此一举
advancementJSON
); // 注册
} catch (IllegalArgumentException e) {
e.printStackTrace();
}
这里和合成表一样,在服务器关闭时要移除已经注册的进度,在 onDisable
方法中:
try {
Bukkit.removeAdvancement(new NamespacedKey(this, "eee_advancement"));
} catch (Exception ignored) {} // 忽略异常
另外,我们还需要在 onEnable
方法前额外打一个注解:
@Override
@SuppressWarnings("deprecation")
public void onEnable() {
// ... 其中内容
}
@SuppressWarnings
用于「压制」编译器警告,这里我们压制了编译器,要求它不显示「使用了不安全的方法」这一警告。当然,不加也是可以的。
我们为这个进度设置的触发器是 minecraft:impossible
,亦即「只能用命令触发」。那我们就使用一个 CLI 来完成它吧。说是 CLI,实际上就是命令啦:
/advancement grant <玩家名> only <进度名>
如果要撤回一个玩家的进度,也很简单:
/advancement revoke <玩家名> only <进度名>
下面我们还是演示「咏 e 之歌」的开发方法,我们要求「在聊天栏中累计发送 91.35 万个 e」,实际上这很简单,我们只需要在玩家聊天时统计一下「e」的个数就行了。
当然,我们需要存储玩家已经输入的「e」数量,这里限于篇幅就不演示了,各位读者应该已经有能力自己实现了吧?
public static Map<UUID, int> eeeCount = new ConcurrentHashMap<>(); // 这里要换成相应的读取代码,自己实现
// 使用 ConcurrentHashMap 的原因是需要线程安全,请参考 7-4 节的内容
@EventHandler
public void countEEE(AsyncPlayerChatEvent e) {
UUID id = e.getPlayer().getUniqueId(); // 基于 UUID 存储数据
int eee = eeeCount.get(id);
if (eee == -1) { // -1 表示已经达成该进度,直接 return
return;
}
String msg = e.getMessage(); // 原先的消息
String after = msg.replace("e", "").replace("E", "");
// 把 e 和 E 都去掉,修改前后字符串长度作差即得到 e 和 E 的数量总和
int eeeS = msg.length - after.length;
eee += eeeS; // 增加 e 的个数
if (eee >= 913500) {
// 91.35 万个 e
eee = -1; // 已经完成
// Bukkit API 不能在异步操作(AsyncXXXEvent)中使用,需要创建单独的线程,从此开始
new BukkitRunnable() {
@Override
public void run() {
Bukkit.dispatchCommand( // 该方法执行命令
Bukkit.getConsoleSender(), // 控制台发送
"advancement grant "
+ e.getPlayer().getName()
+ " only <插件名小写>:eee_advancement"
// 这里需要换成你的插件名小写
);
}.runTask(<插件主类名>.instance);
// 这次使用普通的 runTask 就可以了,到此结束
}.
}
eeeCount.put(id, eee);
// 保存 eeeCount 即可,自己实现
}
唯一需要说明的是这里用到的多线程。按照 Bukkit 规定,Bukkit.XXX
不能在异步事件的处理器中被调用,而聊天用的 AsyncPlayerChatEvent
是个明显的异步事件。
因此我们需要创建单独的线程执行这个命令。这里由于没有什么额外的要求,使用最普通的 runTask
就行了。
打开游戏试试,最后的效果大概是这样的(1.16.5 原版,资源包是 Love-And-Tolerance,语言是 Modern Equish Full):
我没有那么傻,我自己执行了一下 /advancement grant ThatRarityEG only helloworld:eee_advancement
而已啦。
因此我们总结出进度的创建方法:
- 编写一个表示进度的 JSON 文件,主要决定了进度的显示。
- 如果要使用父进度,需要使用
parent
,值为<插件名称小写>:<进度名>
title
和description
所对应的对象都可以使用原始 JSON 文本来设置样式和行为
- 如果要使用父进度,需要使用
- 在
onEnable
方法中保存它们(saveResource
)。 - 在
onEnable
方法中读取它们(BufferedReader
)。 - 在
onEnable
方法中注册它们(loadAdvancement
),注册名就是进度名,小写! - 在
onDisable
方法中注销它们(removeAdvancement
)。 - 编写对应的事件处理器,注意数据的存储。
不难吧?进度确实是一个很好用的游戏元素哦~
第 5 章的内容到此就结束了,你可能会想:什么嘛,这么简单?
是的,第 5 章是「终极」级别篇目,但高级并不意味着困难,笔者一直在挑战自己,到底能够把高级的东西写得多简单,你觉得呢?