| marp | true |
|---|---|
| theme | gaia |
| size | 58140 |
| math | katex |
| backgroundColor | |
| color | |
| style | p { margin: 16px; } section { position: relative; font-family: Bahnschrift; padding-bottom: 32px !important; padding-top: 32px !important; } section.title-page { display: flex; flex-direction: column; align-items: center; justify-content: center; } section::after { content: ""; background-color: #1D1E1F; margin-top: auto; height: 64px; width: 100%; display: flex; flex-direction: row-reverse; z-index: 0; } header { z-index: 1; color: #CFD3DC; } footer { z-index: 1; color: #CFD3DC; } img { max-height: 50vh; max-width: 100%; } strong { color: #409EFF !important; } success { color: #67C23A !important; } warning { color: #E6A23C !important; } danger { color: #F56C6C !important; } info { color: #909399 !important; } purple { color: #9B4DE3 !important; } code { background-color: #0A0A0A; } pre { background-color: #0A0A0A; } |
- Vanilla:Mojang 官方原版,无法加载插件。
- CraftBukkit:第一个广泛使用的插件支持
- Spigot:CraftBukkit 的一个分支,提供了更多的 API 和性能优化。
- Paper:Spigot 的一个分支,在 API 支持和性能上更进一步。
- PurPur???:Paper 的进一步包装,提供更强劲的性能。
插件结合了两者对 Minecraft 游戏世界的修改能力,同时将两者拥有的一些缺陷进行了弥补和隐藏:
我们从三个角度分别来看一下:
- 编写难度:是否能快速上手并编写出功能?
- 安装难度:完成的作品在玩家安装时是否困难?
- 使用效果:是否能够实现足够多样的功能?
- 编写难度:入门极易,精通极其折磨 基于 JSON 和原版命令 (mcfunction)。修改合成表、战利品极快。但若要实现复杂的逻辑(如循环、复杂的数学运算),只能依靠记分板和实体,逻辑链路极难维护。
- 安装难度:完全零门槛(玩家无感) 纯粹依托于游戏存档/服务端。玩家使用完全纯净的原版客户端即可加入,无需任何额外操作。
- 使用效果:局限性强,性能上限低 被死死限制在原版命令允许的框架内。无法连接数据库,无法实现多线程处理,高频复杂的命令运行会严重拖垮服务器计算资源 (TPS)。但是它的一些功能可以和插件配合获得更多的可能性。
- 编写难度:比较困难 需要深厚的 Java 功底,掌握 Mixin 字节码注入,并且需要大致理解晦涩的 Minecraft 底层源码(NMS)。要学的东西很多。
- 安装难度:门槛较高 玩家必须在自己的电脑上安装对应的加载器,并将几十上百个 Mod 文件精确放入客户端文件夹,版本稍微不匹配就会崩溃报错。当然也可以选择整合包一键安装,但这仍然需要一定的操作门槛。
- 使用效果:无所不能,没有边界 客户端和服务端同时运行。可以彻底重写光影渲染引擎、加入全新的维度世界、全新的按键绑定、完全自定义的 GUI 界面系统。
- 编写难度:中等偏上 (需要 Java 基础与 API 知识) 基于高度封装的 Paper/Spigot API,拥有现代编程语言完整的面向对象体验和庞大的开源生态,遇到问题很容易查到现成代码。
- 安装难度:完全零门槛(玩家无感) 纯服务端运行,玩家依然使用原版客户端直连。除了需要在服务端安装对应插件以外,无需任何额外操作。这只会对腐竹产生一些麻烦。
- 使用效果:逻辑控制极强 可以连接 Redis/MySQL,彻底掌控服务器逻辑。唯一遗憾是 不能“无中生有”:无法给客户端注入全新的非原版 3D 渲染模型、按键绑定或全新机制的方块。
- 开发难度适中:不需要掌握复杂的底层技术,只需要掌握 Java 编程语言和 Paper/Spigot API 即可,而 API 这块根本不必担心,因为有详尽的文档。
- 用户友好:玩家根本就不需要操心插件的事情,只要是原版客户端,就能无缝加入服务器。
只是需要可怜一下腐竹 - 功能强大:相较于同样方便的数据包,插件显然有更多的可能性,搭载了 Java 编程体系使得它能够使用所有 Java 生态中的工具和库,能够实现数据持久化、多线程处理、连接数据库、实现复杂的逻辑等等。
在正式认识插件之前,我们要先来理解玩家如何与游戏世界交互:
除了玩家的操作,实体与实体、方块与环境 每时每刻都在发生着不依赖于玩家的交互。这些交互同样会被服务器捕获,并广播为事件。
想象以下场景:
- 僵尸看到了村民并开始追逐。
- 闪电击中了一只猪。
- 悬空的沙子受重力影响开始下落。
- 漏斗将箱子里的物品吸走。
这些事件明显可以监听并触发一些额外的逻辑,来实现一些特殊功能
Tick 是 Minecraft 游戏世界的“心跳”,每秒执行 20 次。
在一个 tick 中,服务器会处理所有的事件,并更新整个世界的状态:
- 处理玩家行为
- 实体与 AI 演算
- 世界环境更新
- 服务器定时任务
如果在这个 50ms 的时间窗口内不能完成所有任务,那么服务器就会卡住,表现为 TPS 下降,玩家操作卡顿。
要想顺利开发 Paper 插件,我们必须要一个合适的开发环境:
- 我们先要一个足够强力的 IDE(IntelliJ IDEA)
- 还要一个有力气的 IDE 插件:Minecraft Development
其它的注意点不用考虑,因为 IDEA 和 Minecraft Development 插件已经帮你做好了所有的考量
当插件项目自动创建完之后,我们就会看到对应的初始代码:
public final class TestPlugin extends JavaPlugin {
@Override
public void onEnable() {
// Plugin startup logic
}
@Override
public void onDisable() {
// Plugin shutdown logic
}
}这是插件的核心,所有的插件逻辑都会围绕这个类展开。
插件的生命周期准确说分为三个阶段,而第一个阶段一半很少用到:
onLoad()- 在插件被读取,但还未被加载到服务器时调用onEnable()- 在插件被加载到服务器时调用onDisable()- 在插件被卸载时调用
非常朴实无华的生命周期,但却能够包含整个插件的运作过程。
onLoad() 生命周期甚至不常用,因为它的作用部分与 onEnable() 重叠了
Bukkit 的事件系统采用了典型的观察者模式 (Observer Pattern),辅以 Java 的反射机制。
public class MyListener implements Listener {
@EventHandler
public void onPlayerJump(PlayerMoveEvent event) { ... }
}implements Listener:这是一个标记接口 (Marker Interface)(里面没有任何方法)。它的唯一作用是告诉服务器这里面有监听器!@EventHandler注解:用这个注解来标记监听器方法。- 参数
(PlayerMoveEvent event):参数类型决定了你要监听什么事件
光定义事件监听器还不够,我们还得把它们注册到服务器中:
public final class TestPlugin extends JavaPlugin {
@Override
public void onEnable() {
PluginManager manager = Bukkit.getPluginManager();
manager.registerEvents(new ExampleListener(), this);
// ..
}
}至此,服务器才明白你注册了一个名为 ExampleListener 的监听器,并会在对应的事件发生时调用内部的对应方法。
默认情况下,我们在 onEnable 里注册所有的监听器,在 plugin.yml 里写死所有的命令。但这种静态方式有两个致命缺陷:
- 性能浪费:假设你写了一个只有每周日开启的插件,那么在其它时间,这个插件其实是不需要运行的,白白损耗了内存和时间。
- 缺乏灵活性:比如想为某个玩家的特定行为注册监听器,或者又不需要这个监听器了,单纯在
onEnable生命周期中注册显然不够灵活。
这个时候,我们需要动态加载/卸载监听器:
动态加载事件很简单,我们刚刚的操作实际上可以在代码的任何一个位置进行,那卸载呢?
Bukkit 的事件系统底层是一个叫做 HandlerList(处理者列表)的结构。所有的 @EventHandler 最终都被装进了这里。
我们前往文档去观察 HandlerList
就会发现,这三个方法的定义是我们实际需要的:
public static void unregisterAll();
public static void unregisterAll(Listener listener);
public static void unregisterAll(Plugin plugin);直接使用 HandlerList.unregisterAll 就可以快速卸载事件响应器了
在插件开发中,我们经常需要实现一些持续生效的效果,比如坐上火箭,在一段时间内持续向上飞行
这种长时间的效果显然超出了一个 tick 可以完成的限度,而直接在事件响应中进行阻塞式的行为会直接导致 TPS 爆炸。
因此,我们需要将这个响应的维度从一个 tick 扩展到多个 tick:
BukkitRunnable task = new BukkitRunnable() {
@Override
public void run() {
// Run anything
}
};BukkitRunnable task = new BukkitRunnable() {
@Override
public void run() {
// Run anything
}
};像这样就能创建一个简单的 BukkitRunnable 实例,其中的 run 方法会在你指定的每个 tick 中执行。
这相当于定义了一个继承 BukkitRunnable 的类,这意味着你可以在其中指定变量并在运行过程中记录状态。
具体运行 BukkitRunnable 有如下几种方法:
task.runTask(Plugin plugin);
task.runTaskLater(Plugin plugin, long delay);
task.runTaskTimer(Plugin plugin, long delay, long period);runTask: 直接在下一个 tick 中运行runTaskLater: 指定的 delay 后运行runTaskTimer: 指定的 delay 后开始运行,然后每隔 period 运行一次
如果中途遇到了需要紧急停止的情况,也可以在 run 方法中使用 cancel 直接取消定时任务
在 CraftBukkit 指定的插件加载接口实际非常生硬,它需要你在两个地方注册命令:
plugin.yaml
# ...
commands:
mycommand:
description: "My command"
usage: "/mycommand"
permission: "me.kingcq.testplugin.mycommand"在 CraftBukkit 指定的插件加载接口实际非常生硬,它需要你在两个地方注册命令:
TestPlugin.java
@Override
public void onEnable() {
PluginCommand myCommand = this.getCommand("mycommand");
MyCommand commandInstance = new MyCommand();
if (myCommand != null) {
myCommand.setExecutor(commandInstance);
myCommand.setTabCompleter(commandInstance);
}
}此外,你还需要在 MyCommand.java 中实现 CommandExecutor 和 TabCompleter 接口:
MyCommand.java
public class MyCommand implements CommandExecutor, TabCompletor {
@Override
public boolean onCommand(
@NotNull CommandSender sender,
@NotNull Command command,
@NotNull String label,
@NotNull String @NotNull [] args
) {
// onTabComplete 同理…
}
}根据文档,在 Paper 中,我们可以继承 BasicCommand 接口并重写对应方法:
public class MyCommand implements BasicCommand之后,只需要在 onEnable() 生命周期中注册这个命令:
@Override
public void onEnable() {
BasicCommand myCommand = new MyCommand();
registerCommand("mycommand", myCommand);
}为了编写一个功能完整的命令,你需要重载这几个方法:
// 必要,决定命令如何运行
public void execute(CommandSourceStack source, String [] args) { ... }
// 非必要,根据当前参数列表给出补全提示
public Collection<String> suggest(
CommandSourceStack commandSourceStack,
String[] args
) { ... }
// 非必要,判断命令发出者是否有权限执行命令
public boolean canUse(CommandSender sender) { ... }
// 非必要,与 `canUse` 互斥,指定命令所需权限
public @Nullable String permission() { ... }如果你注意观察,你会发现只有一个方法是必要的
因此,对于一些简单的命令,可以使用更简单的写法:
registerCommand(
"quickcmd",
(source, args) -> source.getSender().sendRichMessage("<yellow>Hello!")
);通过一个 Lambda 表达式,我们通过定义一个方法来快速创建一个简单命令,而跳过定义一个类来诠释这个命令的全过程
物品的种类包罗万象:无论是砍树的工具、饱腹的食物、建筑的方块,还是合成的材料,在 Paper 底层,它们都被统一抽象为同一个类:ItemStack。
一个基础的 ItemStack 仅仅包含了“材质 (Material)”和“数量 (Amount)”:
// 创建一个包含 64 个钻石的基础物品堆
ItemStack diamonds = new ItemStack(Material.DIAMOND, 64);但是,要想让物品具有 RPG 属性、特殊的说明或者附魔,我们就需要触及物品的 “灵魂” ——ItemMeta。
ItemMeta 掌管着物品的显示名称、Lore(物品描述)、附魔等一切视觉和附加数据。
ItemStack sword = new ItemStack(Material.DIAMOND_SWORD);
ItemMeta meta = sword.getItemMeta();
meta.displayName(
Component.text("★ 霜之哀伤 ★", TextColor.fromCSSHexString("#00FFFF"))
);
meta.lore(List.of(
Component.text("冰封王座的遗产", NamedTextColor.LIGHT_PURPLE),
Component.text("攻击力: +999", NamedTextColor.GOLD)
));
meta.addEnchant(Enchantment.SHARPNESS, 10, true);如果说,我想给铲子一个非常高的攻击伤害,难道说,我要去算锋利附魔要多少级吗?
当然不用,我们可以直接使用 AttributeModifier 来添加自定义属性:
meta.addAttributeModifier(
Attribute.ATTACK_DAMAGE,
new AttributeModifier(
new NamespacedKey("test_plugin", "shovel_attack_damage"),
1024d, AttributeModifier.Operation.ADD_NUMBER,
EquipmentSlotGroup.MAINHAND
)
);痛点:如果我们做了一把特殊的剑,在玩家攻击时触发闪电。我们该如何在代码里判断这把剑是不是那把“特殊剑”? 依赖判断名字(displayName)?绝对不行!玩家可以在铁砧上给普通剑改名来伪造!
用 Lore 是不是可以?如果每种东西的 Lore 都不同,那自然是可以的,但这仍然是笨办法,有没有更优美的解法?
有的,兄弟,有的!
解法:Paper API 提供了 PersistentDataContainer,允许我们在物品内部隐蔽地存储自定义数据。
NamespacedKey key = new NamespacedKey("test_plugin", "weapon_id");
meta.editPersistentDataContainer(container -> container.set(
key,
PersistentDataType.STRING,
"lightning_sword"
));
// 在玩家攻击事件中
String weaponId = meta
.getPersistentDataContainer()
.get(key, PersistentDataType.STRING);
if ("lightning_sword".equals(weaponId)) { ... }比如说,我想让铁镐可以被吃下去?或者铁剑可以堆叠64个?
// 变成可食用的食物
item.setData(
DataComponentTypes.CONSUMABLE,
Consumable.consumable().consumeSeconds(1.6f).build()
);
item.setData(
DataComponentTypes.FOOD,
FoodProperties.food().canAlwaysEat(true).nutrition(100).saturation(100).build()
);
// 变成可堆叠的物品,最大堆叠只能 99
meta.setMaxStackSize(64);更多属性?去查看 ItemMeta 文档!
容器是用来存放物品的矩阵。在 Minecraft 中,它的作用远不止“储存物品”那么简单,容器扮演着三大核心角色:
- 储存空间:玩家背包、箱子、潜影盒,用于数据的持久化存放。
- 制作站:工作台、熔炉、酿造台,用于规则性的物品转换。
- 互动界面 (GUI):这是现代服务器插件的绝对核心!通过创建一个不存在于世界上的虚拟箱子,我们能将其作为与玩家进行丰富交互的菜单。
在代码中,容器的尺寸(Size)必须是 9 的倍数(例如 9, 18, 27... 最大 54)。
我们可以随时无中生有地创建一个虚拟容器,并将刚才精心制作的 ItemStack 放入其中,最后强行展示给玩家:
// 1. 创建一个 3行 (27格) 的虚拟容器,拥有者设为 null,标题自定义
Inventory gui = Bukkit.createInventory(null, 27, Component.text("服务器核心菜单"));
// 2. 槽位计算 (0-26)
// 第一行是 0~8, 我们把物品放在正中间 (第二行第 4 格,即 13)
gui.setItem(13, sword);
// 3. 打开界面
player.openInventory(gui);此时,玩家的前端界面会被强行打开,并看到这把剑静静躺在中间。
再配合上事件响应,就可以做响应式界面了!
在 Paper 插件开发中,我们可以通过代码向服务器动态注入几乎所有的原版配方类型。比较常见的有以下几种:
ShapedRecipe(有序合成):严格要求物品在工作台 3x3 矩阵中的摆放位置(如合成镐子)。ShapelessRecipe(无序合成):只要工作台里有这些材料,随便怎么摆都能合成(如合成谜之炖菜)。FurnaceRecipe(熔炉配方):将被烧炼物转化为产物(也包括BlastingRecipe高炉 和SmokingRecipe烟熏炉)。SmithingTransformRecipe(锻造配方):在锻造台中,通过“模板+基底+附加物”升级物品(如钻石升级下界合金)。
在注册任何一个配方之前,你必须为其指定一个全球唯一的身份证,这就叫 NamespacedKey。
由于现在服务器里可能有几百个插件和数据包,如果大家都叫 my_sword,服务器就会崩溃。因此,NamespacedKey 是由 “插件名(或命名空间)” 和 “配方名” 两部分组成的。
// 生成一个当前插件专属的 Key: "test_plugin:magic_saddle"
NamespacedKey recipeKey = new NamespacedKey("test_plugin", "magic_saddle");如果有一天你需要取消这个合成配方,也是通过向服务器提供这个 Key 来进行注销(Bukkit.removeRecipe(recipeKey))。
NamespacedKey key = new NamespacedKey("test_plugin", "custom_boots");
// 假定这个物品有一些特殊数据
ItemStack result = new ItemStack(Material.IRON_BOOTS);
ShapedRecipe recipe = new ShapedRecipe(key, result);
recipe.shape(
" ",
"F F",
"I I"
);
recipe.setIngredient('L', Material.FEATHER);
recipe.setIngredient('I', Material.IRON_INGOT);
Bukkit.addRecipe(recipe);熔炉配方的注册更加简单直接。你只需要告诉服务器:输入什么、输出什么、给多少经验、需要烧多久?
NamespacedKey key = new NamespacedKey("test_plugin", "flesh_to_leather");
FurnaceRecipe recipe = new FurnaceRecipe(
key,
new ItemStack(Material.LEATHER), // 产物:皮革
Material.ROTTEN_FLESH, // 原料:腐肉
1.0f, // 经验奖励:1.0点
200 // 烧炼时间:200 Tick (10秒)
);
Bukkit.addRecipe(recipe);经典的腐肉烧皮革
Paper 借助 Adventure API 提供了极其优雅的原生 UI 控制:
| UI 类型 | 适用场景与特性 |
|---|---|
| ActionBar | 位于快捷栏上方。适合高频刷新的短信息,不遮挡视野。 |
| BossBar | 位于屏幕正上方,带进度条。适合展示全局或小队状态。 |
| Title | 占据屏幕正中央。适合极其重要的强提醒 |
- 发送高频动作条 (ActionBar):
player.sendActionBar(Component.text("法力值: [|||||||| ] 80/100"));- 创建并展示 BossBar:
// 创建一个红色、进度为 50%、分成 10 截的 BossBar
BossBar captureBar = BossBar.bossBar(
Component.text("A 点占领中..."),
0.5f, BossBar.Color.RED,
BossBar.Overlay.NOTCHED_10
);
player.showBossBar(captureBar);Scoreboard 是 Minecraft 中最强大的数据与团队管理系统。它的首要功能是展示右侧的信息栏 (Sidebar)。
ScoreboardManager manager = Bukkit.getScoreboardManager();
Scoreboard board = manager.getNewScoreboard();
Objective obj = board.registerNewObjective(
"game_stats",
"dummy",
Component.text("★ 战区数据 ★")
);
obj.setDisplaySlot(DisplaySlot.SIDEBAR); // 显示在屏幕右侧
obj.getScore("当前击杀:").setScore(5);
obj.getScore("剩余存活:").setScore(12);
player.setScoreboard(board);计分板不仅仅能显示侧边栏,它里面还包含着一个极其强大的子系统:队伍 (Team)。
// 在刚才的 board 中注册一个“红队”
Team redTeam = board.registerNewTeam("Red");
// 设置队伍前缀与颜色
redTeam.prefix(Component.text("[红队] ", NamedTextColor.RED));
redTeam.color(NamedTextColor.RED);
// 开启队伍友伤豁免
redTeam.setAllowFriendlyFire(false);
// 将玩家加入红队 (注意参数传的是玩家的名字字符串)
redTeam.addEntry(player.getName());队伍系统掌管着玩家头顶名字 (NameTag) 的渲染规则。掌握它,你就能做出硬核的潜行游戏。
需求 1:隐藏头顶名字
redTeam.setOption(Team.Option.NAME_TAG_VISIBILITY, Team.OptionStatus.NEVER);需求 2:透视发光描边
// 只要刚才设置了 redTeam.color(NamedTextColor.RED);
// 当我们赋予玩家发光属性时,客户端渲染出来的轮廓线就会变成红色
player.setGlowing(true);YAML 是一种类似 JSON 的结构化数据格式,可以表示多层次多类型的复杂结构化数据:
players:
-
name: "kingcq"
age: 22
tags: ["student", "developer"]
op: true
-
name: "stridebeach",
age: 21
tags: ["strange", "hyw"]
op: falsePaper 服务器插件的配置文件就推荐使用 YAML 格式编写,并提供了丰富的语法支持:
@Override
public void onEnable() {
File file = new File(getDataFolder(), "myconfig.yml");
YamlConfiguration myconfig = YamlConfiguration.loadConfiguration(file);
}而想要获取到配置文件中的内容,比如说我要获得 players 中第一个人的 name,只需要:
String name = myconfig.getString("players.0.name");YamlConfiguration 提供了充足的接口用来获取配置文件中的数据。
而对于其中的 path 参数,想必你也能猜到,它实际上是一个 String 类型的路径,用来指定配置文件中的具体位置:
String name = myconfig.getString("players.0.name");
String age = myconfig.getString("players.0.age");
List<String> tags = myconfig.getStringList("players.0.tags");
boolean op = myconfig.getBoolean("players.0.op");特别地,你可以使用 getConfigSection() 来继续拆分 YAML 文件:
ConfigurationSection section = myconfig.getConfigurationSection("players.0");
section.getString("name");YamlConfiguration 提供了 save() 方法来保存配置文件:
myconfig.save(new File(getDataFolder(), "myconfig.yml"));那对于其中具体的数据,如何修改呢?
YamlConfiguration 提供了 set() 方法,可以将指定的 path 字段的内容修改为任何支持的类型:
myconfig.set("players.0.name", "aintcecily");
myconfig.set("players.1.age", -1);特别地,如果后面要设置的值为 null,这条字段会直接被删除。
你应当也注意到了,读写配置文件一定是对整个文件进行操作,在配置文件比较大的情况下,这意味着大量的磁盘读写开销。
因此,为了尽可能避免大量的开销,我们最好只在 onEnable 和 onDisable 生命周期中读取和保存配置文件。
当然,你也可以选择 懒加载 的思路,当需要配置文件的时候再去加载,但 在整个插件生命周期中只加载一次。
担心服务器被强制 kill 了,配置文件没来得及保存?
除了定时保存配置文件以外,这种情况确实没招,受着
哪怕是原版的 Minecraft,生成一个 Chunk(16x16x384的区块)也要经历极其严密且复杂的流水线加工。在 Bukkit API 的 ChunkGenerator 中,这条流水线被完美地暴露给了我们:
我们可以选择性的调整其中的任何一个步骤来影响世界生成。
为了让服务器知道我们的插件有生成器,以及如何获取它,我们可以在 Plugin 主类中重写 getDefaultWorldGenerator() 方法:
@Override
public ChunkGenerator getDefaultWorldGenerator(String worldName, String id) {
return new MyCustomGenerator();
}同时,根据 Bukkit 配置文档,我们需要在 bukkit.yml 中指定接管世界生成的插件,使用插件配置中的 name 来定位插件:
worlds:
world:
generator: TestPlugin如果你不想破坏原版主世界,而是想利用代码 “凭空创造一个全新的副世界或游戏房间”,可以使用 Bukkit 提供的 WorldCreator API:
WorldCreator creator = new WorldCreator("alien_dimension");
creator.generator(new MyCustomGenerator());
creator.generateStructures(false);
creator.biomeProvider(new MyBiomeProvider());
World alienWorld = Bukkit.createWorld(creator);直接删除世界显然是不可取的,因为世界正在被服务器占用!
要彻底抹除一个世界,我们必须执行严密的两步销毁:
- 清场与内存卸载 (Unload):斩断服务器对该世界的一切资源引用。
- 物理毁灭 (File I/O):利用 Java 底层文件操作,擦除硬盘痕迹。
核心方法是 Bukkit.unloadWorld(),不过请注意文档:
文档中提示我们不能在世界正在 tick 的情况下卸载它:
“一个 Tick”并不等同于“世界正在 Tick”。Minecraft 服务端在一个 50ms 的 Tick 生命周期里,是严格分阶段(Phases)执行的:
- 📥 网络封包处理阶段
- ⚙️ Bukkit 调度器任务执行阶段 (
runTask) 👈 我们的代码在这里! - 🌍 世界 Tick 阶段 (
isTickingWorlds = true) 👈 事件在这里触发! - 📤 数据同步与发包阶段
所以,目标其实很简单,我们使用一个 BukkitRunnable 来包装一下就可以了。
World toDelete = Bukkit.getWorld("nether");
if (toDelete != null) {
BukkitRunnable task = new BukkitRunnable() {
@Override
public void run() {
for (Player player : toDelete.getPlayers()) {
player.teleport(player.getRespawnLocation());
}
for (Chunk chunk : toDelete.getLoadedChunks()) {
chunk.unload(false);
}
Bukkit.unloadWorld(toDelete, false);
}
};
task.runTask(TestPlugin.plugin);
}
}然后从物理层面删除世界,别忘了留足 unload 世界的耗时:
BukkitRunnable deleteTask = new BukkitRunnable() {
@Override
public void run() {
File worldFolder = new File(
Bukkit.getWorldContainer(), toDelete.getName()
);
deleteWorldFolder(worldFolder);
}
};
deleteTask.runTaskLater(this, 2L);至于 deleteWorldFolder,顺手的事:
public void deleteWorldFolder(@NotNull File path) {
if (path.exists()) {
File[] files = path.listFiles();
if (files != null) {
for (File file : files) {
if (file.isDirectory()) {
deleteWorldFolder(file);
} else {
file.delete();
}
}
}
path.delete();
}
}一切的前提是群系。 它是冷还是热?是沙漠还是平原?这决定了后续表皮铺什么方块、下不下雪。
在 ChunkGenerator 中重写对应方法,根据信息返回群系:
public class MyBiomeProvider extends BiomeProvider {
@Override
public Biome getBiome(WorldInfo worldInfo, int x, int y, int z) {
return Biome.LUSH_CAVES;
}
@Override
public List<Biome> getBiomes(WorldInfo worldInfo) {
return List.of(Biome.LUSH_CAVES);
}
}有了群系,接下来是搭骨架。这一步只负责确定哪些地方是实心的,哪些地方是空的。 注意:X 和 Z 是局部坐标 (0~15),Y 是绝对坐标!
@Override
public void generateNoise(
WorldInfo worldInfo, Random random,
int chunkX, int chunkZ, ChunkData chunkData
) {
for (int x = 0; x < 16; ++x) {
for (int z = 0; z < 16; ++z) {
for (int y = chunkData.getMinHeight(); y < Math.min(60, chunkData.getMaxHeight()); ++y) {
chunkData.setBlock(x, y, z, Material.STONE);
}
}
}
}骨架搭好了,全是灰扑扑的石头。现在我们要上色。 这一步通常结合第一步的群系。
@Override
public void generateSurface(
WorldInfo worldInfo, Random random, int chunkX, int chunkZ, ChunkData chunkData
) {
// 我们在这个阶段寻找最顶层的石头,并把它替换成我们想要的表面
for (int x = 0; x < 16; x++) {
for (int z = 0; z < 16; z++) {
chunkData.setBlock(x, 60, z, Material.MAGMA_BLOCK);
chunkData.setBlock(x, 59, z, Material.NETHERRACK);
}
}
}这两步分别处理世界的 “托底” 和 “内部镂空”。
(在现代开发中,如果我们不想自己写复杂的洞穴雕刻算法,可以直接 return 交给原版的生成器处理!)
@Override
public void generateBedrock(
WorldInfo worldInfo, Random random, int chunkX, int chunkZ, ChunkData chunkData
) {
for (int x = 0; x < 16; x++) {
for (int z = 0; z < 16; z++) {
chunkData.setBlock(x, worldInfo.getMinHeight(), z, Material.BEDROCK);
}
}
}整体框架完成了,剩下的任务就是点缀了。在 getDefaultPopulators 里返回各种装饰器,用来种树、撒矿石、长杂草。
注意:Populator 阶段使用的是世界绝对坐标!
public class OrePopulator extends BlockPopulator {
@Override
public void populate(
WorldInfo worldInfo, Random random, int chunkX, int chunkZ, LimitedRegion region
) {
int worldX = (chunkX << 4) + random.nextInt(16);
int worldZ = (chunkZ << 4) + random.nextInt(16);
int y = 50;
if (region.getType(worldX, y, worldZ) == Material.STONE) {
region.setType(worldX, y, worldZ, Material.DIAMOND_ORE);
}
}
}在游戏中用原版的“结构方块 (Structure Block)”保存一个 .nbt 建筑模型文件,然后在 Populator 阶段,使用 Bukkit 的 StructureManager 强行将这个建筑“粘贴”到世界上!
@Override
public void populate(
WorldInfo worldInfo, Random random, int chunkX, int chunkZ, LimitedRegion region
) {
if (random.nextDouble() < 0.01) {
// 1. 加载我们在 resources 文件夹里放好的 nbt 结构文件
NamespacedKey key = new NamespacedKey("test_plugin", "player_house");
Structure structure = Bukkit.getStructureManager().getStructure(key);
// 2. 将建筑强行粘贴到指定坐标 (世界绝对坐标)
Location loc = new Location(null, chunkX << 4, 61, chunkZ << 4);
structure.place(region, loc, false, StructureRotation.NONE, Mirror.NONE, 0, 1.0f, random);
}
}如果采用“内部打包”的方案,我们不能直接使用 getStructure(key)(它默认去 world 文件夹里找)。我们需要用 Java 经典的 InputStream(输入流)技术,直接从插件的 .jar 压缩包内把文件“抽”出来:
Structure myStructure;
try {
InputStream stream = plugin.getResource("structures/player_house.nbt");
myStructure = Bukkit.getStructureManager().loadStructure(stream);
} catch (Exception e) {
plugin.getLogger().severe("无法加载结构文件!");
}无论你的 Java 代码写得多么逆天,纯粹的服务端插件是绝对不可能凭空把一张全新的 .png 物品贴图塞进玩家客户端内存里的!
Minecraft 是 C/S(客户端/服务端)架构。服务器只能告诉客户端:“玩家手里拿的是一根木棍 (Stick)”。至于这根木棍长什么样,完全由客户端本地的文件决定。
✨ 破局之法: 让服务器向玩家发送一个 资源包 (Resource Pack) 的下载链接。客户端下载后,就会用包里的新图片来渲染游戏画面!
材质包本质上是一个具有严格目录结构的 .zip 压缩包。
首先,我们需要在根目录建立 pack.mcmeta(材质包的身份证):
下面这段 JSON 是通用的,可以适用于任何版本的材质包,但考虑到之后用到的语法比较新,Minecraft 版本最好还是在 1.21.4+ 为好。
{
"pack": {
"description": "Auto-generated resource pack for TestPlugin",
"pack_format": 9999,
"supported_formats": [0, 9999],
"min_format": 0,
"max_format": 9999
}
}
在 Paper 1.21.4+ 引入了数据组件之后,我们终于可以在 CustomModelData 中使用语义化的字符串了!
ItemStack magicWand = new ItemStack(Material.STICK);
ItemMeta meta = magicWand.getItemMeta();
meta.displayName(Component.text("★ 火花法杖 ★"));
meta.setItemModel(new NamespacedKey("test_plugin", "flame_wand"))
CustomModelDataComponent cmd = meta.getCustomModelDataComponent();
cmd.setStrings(List.of("test_plugin:flame_wand"));
meta.setCustomModelDataComponent(cmd);
magicWand.setItemMeta(meta);有了字符串暗号,我们要教客户端怎么认出它。一个现代的材质包 .zip 结构如下:
my_resource_pack/
├── assets
│ └── test_plugin
│ ├── items
│ │ └── copper_sword.json
│ ├── models
│ │ ├── blade_1.json
│ │ ├── hilt_1.json
│ └── textures
│ └── item
│ ├── copper_blade.png
│ └── copper_hilt.png
└── pack.mcmeta
📝 JSON:物品模型映射
{
"model": {
"type": "composite",
"models": [
{
"type": "select",
"property": "custom_model_data",
"index": 0,
"fallback": {
"type": "empty"
},
"cases": [
{
"when": "copper",
"model": {
"type": "model",
"model": "test_plugin:hilt_1"
}
},
]
},
]
}
}材质包做好了,怎么发给玩家?
我们可以利用 Java 自带的 HttpServer,将资源包托管到某个端口:
HttpServer server = HttpServer.create(new InetSocketAddress(8080), 0);
server.createContext("/pack.zip", exchange -> {
byte[] packData = Files.readAllBytes(Paths.get("plugins/AwesomePlugin/pack.zip"));
exchange.sendResponseHeaders(200, packData.length);
exchange.getResponseBody().write(packData);
exchange.getResponseBody().close();
});
server.start();之后通过 Paper API 请求玩家下载资源包就可以了




