Bukkit NMS 开发实践 —— 创建你自己的自定义实体(适用于 1.16.3 – 1.16.5 版本)

发布于 2021-06-16  16 次阅读


Bukkit NMS 开发实践 —— 创建你自己的自定义实体(适用于 1.16.3 - 1.16.5 版本)

什么是 NMS?

NMS 是 net.minecraft.server 包的简写,是 CraftBukkit 服务端及其下游服务端的底层实现,其代码包含 Mojang 发布的 Vanilla 服务端代码和 SpigotMC 添加的、用于与 BukkitAPI 进行交互的代码。在开发者无法借助 BukkitAPI 完成所需要的功能时,开发者我常常使用 NMS 进行开发。NMS 开发是底层行为,同时跨版本兼容性较差,除非必须使用,否则还请尽量使用 BukkitAPI。NMS 仅存在于编译后的服务端内部,不属于 BukkitAPI 内容。各版本的 NMS 包名一般均为 net.minecraft.server.v版_本_R号,如 net.minecraft.server.v1_16_R3。NMS 包内为扁平结构,没有二级包。NMS 包内类名为 Spigot 定义的反混淆名;方法、字段名一部分为 Spigot 定义的反混淆名,一部分为原混淆名;方法参数名一般为原混淆名。本教程旨在教授 Bukkit 开发者以 NMS 使用方法,拓展 Bukkit 开发者的开发视野。

自 1.17 后,SpigotMC 开始提供 Mojang 混淆表版本的 Spigot 服务端,这意味着大大简化了开发难度 —— 不需要再对照混淆表一个一个看 NMS 方法名,而可以像 Forge 或是 Fabric 开发者一样使用各自的反混淆代码直接进行开发 —— 只需要使用 Spigot 提供的 SpecialSource 工具将 Mojang Mapping 转换回 obf 版本即可发布。

如何使用 NMS?

要想使用 NMS,您必须手动导入编译好的 CraftBukkit/Spigot 服务端核心,这样才能获取其中内置的 NMS。对于 Paper 及其下游服务端来说,不应该直接导入服务端核心本体,而应该导入运行一次服务端后生成的 patched_x.x.x.jar 文件。

教程:创建自定义实体

很显然,BukkitAPI 没有向我们提供自定义实体的功能,甚至,实体的类型是确定的,不能更改的。因此,要想自定义实体,必须使用 NMS。当然,我们并不能创建 Forge 或是 Fabric 意义上完全自定义模型的实体。但是,我们能够通过继承原版存在的实体,创建一个新的实体类型,为这个新的实体类型指定一些交互。本例中,我们将会通过创建一个会在夜间燃烧、不做任何交互、拥有 Boss 血条的巨人僵尸来演示这一过程。

继承已有实体

让我们创建 EntityCustomGiantZombie 类,继承 net.minecraft.server.v1_16_R3.EntityGiantZombie 类:

public class EntityCustomGiantZombie extends EntityGiantZombie {}

接下来,初始化该实体,实现超类构造器:

public EntityCustomGiantZombie(EntityTypes<? extends EntityGiantZombie> var0, World var1) {
        super(var0, var1);
}

注意,此处的 World 不是我们熟识的 org.bukkit.World 接口,而是 net.minecraft.server.v1_16_R3.World 抽象类,因此不能一概而论。

当然,我们可以通过以下代码实现 Bukkit World 和 NMS World 的互转:

//Bukkit World to NMS World
org.bukkit.World bukkitWorld = nmsWorld.getWorld();
// NMS World to Bukkit World
net.minecraft.server.v1_16_R3.World nmsWorld = ((CraftWorld) bukkitWorld).getHandle();

其实,调用 net.minecraft.server.v1_16_R3.World#getHandle() 返回的并非 org.bukkit.World 接口,而是 org.bukkit.craftbukkit.v1_16_R3.CraftWorld 类,其为 org.bukkit.World 在 CraftBukkit 服务端中的内部实现,因此可以直接转换到 World 接口。事实上,nmsWorld#getWorld() 方法返回的也是 CraftWorld 类。

要想生成该实体,则应该调用 WorldServer#addEntity(Entity, SpawnReason) 方法初始化实体,然后使用 Entity#setPositionRotation(double, double, double, float, float) 传送实体到出生位置。为了简便流程,我们可以创建一个可传入 Bukkit Location,并可以自动设置实体出生位置的构造函数:

public EntityCustomGiantZombie(Location loc) {
        this(EntityTypes.GIANT, ((CraftWorld) loc.getWorld()).getHandle());
        setPositionRotation(loc.getX(), loc.getY(), loc.getZ(), loc.getYaw(), loc.getPitch());
    }

然后,在适当的位置初始化该实体,比如,某一个 Bukkit EventListener 中:

((CraftWorld) e.getPlayer().getWorld()).getHandle().addEntity(new EntityCustomGiantZombie(e.getPlayer().getLocation()), CreatureSpawnEvent.SpawnReason.CUSTOM);

这样,你就能看到一个由你自定义的巨人僵尸实体了!

添加 Boss 血条

接下来,我们尝试向这个自定义实体添加 Boss 血条。

添加 Boss 血条大概需要有三步操作:

  1. 当玩家进入追踪视野时显示 Boss 血条
  2. 当玩家离开追踪视野时隐藏 Boss 血条
  3. 当怪物受到攻击时令 Boss 血条相应减少血量

首先,我们需要定义一个 Boss 血条。在 EntityCustomGiantZombie 类中添加以下字段:

private final BossBattleServer bossBar;

并在底层构造器中初始化这个 Boss 血条:

bossBar = new BossBattleServer(new ChatComponentText("Boss 血条示例").a(EnumChatFormat.GOLD), BossBattle.BarColor.BLUE, BossBattle.BarStyle.NOTCHED_12);
        bossBar.setDarkenSky(true);
    }

这初始化了一个血条名为金色的 "Boss 血条示例",血条颜色为蓝色的,1/12 比例风格的,在玩家显示 Boss 血条时时天空变暗的 Boss 血条。

然后,我们需要覆盖 void b()void c() 两个方法,这两个方法在 MCP 中描述如下:

/**
    * Add the given player to the list of players tracking this entity. For instance, a player may track a boss in order
    * to view its associated boss bar.
    */
   public void addTrackingPlayer(ServerPlayerEntity player) {
   }

   /**
    * Removes the given player from the list of players tracking this entity. See {@link Entity#addTrackingPlayer} for
    * more information on tracking.
    */
   public void removeTrackingPlayer(ServerPlayerEntity player) {
   }

这正是我们需要的,可以动态显示和隐藏 Boss 血条的方法。覆盖这些方法,并添加一些内容:

    @Override
    public void b(EntityPlayer entityplayer) {
        super.b(entityplayer);
        this.bossBar.addPlayer(entityplayer);
    }

    @Override
    public void c(EntityPlayer entityPlayer) {
        super.c(entityPlayer);
        this.bossBar.removePlayer(entityPlayer);
    }

最后,覆盖 void tick() 方法,该方法一看名字就知道是干什么的了:

    @Override
    public void tick() {
        super.tick();
        this.bossBar.setProgress(getHealth() / getMaxHealth());
    }

其中 bossBar.setProgress(float) 接受一个单精度浮点数,为血条剩余的血量百分比。

需要注意的是,一定要调用 super.tick(),否则该怪物完全被冻结,不会产生任何交互。

让怪物在夜间燃烧

要想让怪物在夜间燃烧,则需要在每 tick 检测怪物是否处于夜间环境,如果是,则使怪物燃烧。因此,在 tick() 方法键入以下代码:

if (world.isNight()) setOnFire(1, false);

其中 setOnFire(int, boolean) 的第一个参数为燃烧的 tick 数,由于是 1 tick 检测一次,因此我们在这里填写 1;第第二个参数为是否触发 BukkitAPI 的 EntityCombustEvent 事件,为了避免事件被多次调用,这里我们填写 false

这样,怪物就会在夜间燃烧了。

自定义怪物行为

要想自定义怪物行为,我们需要为怪物添加 PathfinderGoal,因为我们不希望保留怪物原本的行为,因此我们需要刷新怪物的 goalSelector(行为选择器) 和 targetSelector(攻击目标选择器)。他们均为 PathfinderGoalSelector 类型的对象。经过研究该对象我们可以发现,PathfinderGoal 对象被包装为 PathfinderGoalWrapped 对象后,存储于private final Set<PathfinderGoalWrapped> d 字段中,由于该字段是 private final 的,因此我们需要通过反射修改该字段。在怪物的底层构造器中键入以下代码:

try {
            Field dField = PathfinderGoalSelector.class.getDeclaredField("d");
            dField.setAccessible(true);
            dField.set(goalSelector, Sets.newLinkedHashSet());
            dField.set(targetSelector, Sets.newLinkedHashSet());
        } catch (NoSuchFieldException | IllegalAccessException noSuchFieldException) {
            noSuchFieldException.printStackTrace();
        }

这样,我们便得到了一个没有任何行为的怪物。如果我们还需要为怪物添加行为,只需要为 goalSelector 或是 targetSelector 添加继承了 PathfinderGoal 类的对象即可。NMS 中本身就包含了大量的 PathfinderGoal,大家可自行探索。