数据包
PacketRegistry 提供 vanilla packet 拦截、公开 packet view、直接发送、custom payload 和 per-viewer illusion。packet 类型和 view 接口由数据生成器生成,位于 API jar 中;源码仓库里对应生成源在 fand-api/build/generated/sources/fandData/main/java。
IMPORTANT
不要根据 fand-api 接口中的兼容默认方法判断运行时是否支持某项 packet 能力。插件代码应以目标 Fand Server 运行时提供的 PacketRegistry、PacketSender 和 view 实现为准。
查找 PacketType
生成的 PacketType 枚举包含协议阶段、方向、Minecraft packet key、view 类型和底层类名。
import io.fand.api.packet.PacketDirection;
import io.fand.api.packet.PacketProtocol;
import net.kyori.adventure.key.Key;
var type = context.packets().type(
PacketProtocol.PLAY,
PacketDirection.CLIENTBOUND,
Key.key("minecraft:system_chat"));常用情况下可以直接使用生成常量:
import io.fand.api.packet.PacketType;
var type = PacketType.PLAY_CLIENTBOUND_SYSTEM_CHAT;拦截数据包
拦截器会拿到 PacketController。可以读取 view、替换 view 或取消数据包。
import io.fand.api.packet.PacketType;
context.packets().intercept(PacketType.PLAY_CLIENTBOUND_SYSTEM_CHAT, controller -> {
var view = controller.view();
if (view.has("content")) {
context.logger().debug("system chat packet fields={}", view.fields());
}
});如果使用生成 view 类型,可以让 API 做类型转换:
import io.fand.api.packet.view.ClientboundSystemChatPacketView;
context.packets().intercept(
PacketType.PLAY_CLIENTBOUND_SYSTEM_CHAT,
ClientboundSystemChatPacketView.class,
controller -> {
var view = controller.view();
context.logger().debug("packet type={}", view.packetType());
});字段名来自生成 view 和运行时 packet metadata。写底层数据包逻辑时,先用 fields() 打印可用字段,再决定读取或替换哪个字段。
构造和发送
可以使用通用 builder 设置字段并构造 PacketView:
var packet = context.packets()
.builder(PacketType.PLAY_CLIENTBOUND_SET_ACTION_BAR_TEXT)
.field("text", Component.text("Hello"))
.build();
context.packets().sender().send(player, packet);PacketBuilder.send(player) 是便捷写法:
context.packets()
.builder(PacketType.PLAY_CLIENTBOUND_SET_ACTION_BAR_TEXT)
.field("text", Component.text("Saved"))
.send(player);Illusion
ViewerIllusionService 面向“只让某个玩家看到”的表现效果。
var illusions = context.packets().illusions();
illusions.fakeBlock(player, block.location(), previewType);
illusions.hideEntity(player, entity);
illusions.showEntity(player, entity);fakeBlock、fakeEntity、removeFakeEntity 和 sendPacket 都是 per-viewer 操作,不改变真实世界状态。运行时会拒绝发送给已断开连接的玩家;fakeBlock 还要求目标位置和 viewer 在同一个世界,fakeEntity 只接受 clientbound packet view。
Helper Builder
PacketHelpers 提供一些常见 clientbound packet 的 builder:
var spawn = context.packets().helpers()
.displayEntity(entityId, uniqueId, entityType, location)
.build();
context.packets().illusions().fakeEntity(player, spawn);helper 只负责填充常用字段。更复杂的 metadata、display entity、nameplate 或 fake screen 仍然可以在 builder 上继续补字段。
当前 helper 覆盖:
entityMetadata
displayEntity / hologramEntity
scoreboardTeam / nameplateTeam
openScreen从零构造 packet 时,需要提供该 packet 在运行时转换所需的字段;修改已拦截 packet 时,优先从原 view 复制字段后只替换你关心的值。
使用建议
- 优先用高层 API,例如 GUI、BossBar、TabList、Scoreboard;只有高层 API 不够时再直接操作 packet。
- 拦截器里不要做长耗时 I/O。
- 替换 packet 时尽量保留原 view 中你不关心的字段。
- packet view 和字段随 Minecraft 协议演进,升级服务端前要重新测试依赖底层字段的插件。
为什么这样设计
Packet API 是 Fand 的低层逃生口。GUI、BossBar、TabList、Scoreboard、Map 和 Placeholder 这类高层 API 更稳定,应该优先使用;只有当你需要协议级表现、兼容特定客户端功能、或做 per-viewer illusion 时,才直接碰 packet。
PacketView 使用生成 metadata 暴露字段,是为了避免插件直接依赖混淆后的内部 packet 类。这样插件代码可以用公开字段名和 view 类型表达意图,运行时负责把 view 转换成当前服务端版本的实际 packet。
builder 不会替你理解某个 vanilla packet 的完整协议。它只收集字段并交给运行时构造 view;如果缺少运行时转换所需字段,构造或发送阶段会失败。
最佳实践
- 先用
fields()打印实际 packet 字段,再写读取和替换逻辑。 - 修改拦截到的 packet 时,用
view.with(...)或复制原字段,只改你关心的字段。 - 从零构造 packet 时,优先使用
PacketHelpers,再补充缺失字段。 - fake block/entity 只用于单个 viewer 的表现层,不要把它当成世界状态。
- 拦截器里只做轻量判断;复杂处理放到异步任务或高层状态机。
- 升级 Minecraft/Fand 版本后,重新测试依赖字段名和底层 packet 结构的插件。
常见坑
- 直接构造 packet 时漏掉必需字段,导致运行时无法转换。
- 把 fake block 当成真实方块:服务端世界不会改变,玩家重新同步或交互后可能看到真实状态。
fakeEntity需要 clientbound spawn packet view;serverbound 或无关 packet 不适合用来生成假实体。- packet 字段不是公共业务模型,协议升级时可能变化。
- 拦截器抛异常会影响 packet 派发路径;必要时自己捕获并记录。
综合示例:动作栏提示和系统聊天审计
下面的例子用 helper/builder 发送动作栏文本,并拦截系统聊天包打印字段。真实插件可以先观察字段,再决定是否替换或取消。
package com.example;
import io.fand.api.entity.Player;
import io.fand.api.packet.PacketType;
import io.fand.api.packet.view.ClientboundSystemChatPacketView;
import io.fand.api.plugin.Plugin;
import io.fand.api.plugin.PluginContext;
import net.kyori.adventure.text.Component;
public final class ExamplePlugin implements Plugin {
@Override
public void onEnable(PluginContext context) {
context.packets().intercept(
PacketType.PLAY_CLIENTBOUND_SYSTEM_CHAT,
ClientboundSystemChatPacketView.class,
controller -> context.logger().debug(
"system chat fields={}",
controller.view().fields()));
}
public void sendSavedHint(PluginContext context, Player player) {
context.packets()
.builder(PacketType.PLAY_CLIENTBOUND_SET_ACTION_BAR_TEXT)
.field("text", Component.text("Saved"))
.send(player);
}
}