Skip to content

Latest commit

 

History

History
295 lines (221 loc) · 12.9 KB

5-7.md

File metadata and controls

295 lines (221 loc) · 12.9 KB

5-7 自定义进度

这是我临时添加的一节,我也是纠结了好久,决定是否要写这一节。

自定义进度有两个办法:

  • 使用 NMS 提供的功能(呕——)
  • 使用 Bukkit 的「Unsafe」方法

虽然按照我的准则,我很少使用 @Deprecated 的方法,但相比之下,我更不想写反射代码(笑)!

基于 JSON 的进度

出人意料的是,进度并不是 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 是框架, 只有 taskgoalchallenge 三个选择,决定了该进度在进度窗口的显示形状以及完成时的提示文本。
  • 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 用一条横线划掉了。

UNSAFE.png

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 读取失败,这个方法可能会出错,因此我们需要将它包裹在 trycatch 中,完整的 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):

GET

L

我没有那么傻,我自己执行了一下 /advancement grant ThatRarityEG only helloworld:eee_advancement 而已啦。

因此我们总结出进度的创建方法:

  1. 编写一个表示进度的 JSON 文件,主要决定了进度的显示。
    • 如果要使用父进度,需要使用 parent,值为 <插件名称小写>:<进度名>
    • titledescription 所对应的对象都可以使用原始 JSON 文本来设置样式和行为
  2. onEnable 方法中保存它们(saveResource)。
  3. onEnable 方法中读取它们(BufferedReader)。
  4. onEnable 方法中注册它们(loadAdvancement),注册名就是进度名,小写!
  5. onDisable 方法中注销它们(removeAdvancement)。
  6. 编写对应的事件处理器,注意数据的存储。

不难吧?进度确实是一个很好用的游戏元素哦~


第 5 章的内容到此就结束了,你可能会想:什么嘛,这么简单?

是的,第 5 章是「终极」级别篇目,但高级并不意味着困难,笔者一直在挑战自己,到底能够把高级的东西写得多简单,你觉得呢?