Table of Contents
注意:本页已经取代旧版页面。建议使用本页描述的新的网络API。旧的页面参见此处(英文)。
对于 1.20.5 引入的新的网络通信 API,请参见 1.20.5 中的网络通信。
网络通信
Minecraft 中的网络通信用于客户端与服务器相互通信。网络是个比较广泛的主题,所以本页分成了几个类别。
例子:网络通信为何重要?
一段简单的代码可以清楚地展示网络通信有多重要。您*不需要*使用这段代码,这段代码只是用来解释网络通信的重要性的。
假如你有根魔杖,可以向附近的所有玩家展示你正在查看的方块。
class HighlightingWandItem extends Item { public HighlightingWand(Item.Settings settings) { super(settings) } public TypedActionResult<ItemStack> use(World world, PlayerEntity user, Hand hand) { // 视线追踪并找到你面对的方块 BlockPos target = ... // 不好的代码:别这么写: ClientBlockHighlighting.highlightBlock(MinecraftClient.getInstance(), target); return super.use(world, user, hand); } }
测试后,你会看到你面对的方块高亮了,并且没有崩溃。现在你想向你的朋友展示这个模组,启动一个专用服务器并邀请朋友安装模组。你使用了这个物品,结果服务器崩了……你可能会注意到崩溃日志中有这样的错误:
[Server thread/FATAL]: Error executing task on Server java.lang.RuntimeException: Cannot load class net.minecraft.client.MinecraftClient in environment type SERVER
为什么服务器崩溃?
因为代码调用的逻辑只有 Minecraft 的客户端分发中存在。Mojang 这样分发游戏的是为了减少 Minecraft 服务器的 jar 文件的大小。服务器没有理由包含整个渲染引擎,渲染引擎只会在你自己的机器渲染世界时才会用到。在开发环境中,一些类注解了 @Environment(EnvType.CLIENT)
,表示这些类仅存在于客户端。
怎样修复崩溃?
要修复崩溃,你需要了解 Minecraft 如何在客户端和专用服务器之间通信(communication)。
从上面这张图可以看到,游戏客户端和专用服务器是相互分离的系统,并使用数据包(packets,注意不是 datapack)桥接在一起。这个数据包桥(packet bridge)不仅存在于游戏客户端和专用服务器之间,还存在于您的客户端和通过 LAN 连接的另一个客户端之间。注意即使是单人游戏也有数据包桥,这是因为游戏客户端会启动一个特殊的集成服务器实例来运行游戏。下表显示了三种连接类型之间的主要区别:
连接类型 | 访问游戏客户端 |
---|---|
连接至专用服务器 | 不可以 → 服务器崩溃 |
通过局域网(LAN)连接 | 可以 → 非主机游戏客户端 |
单人(或者LAN主机) | 可以 → 完全访问 |
这样以三种不同的方式与服务器进行通信看上去很复杂,但您不并需要以三种不同的方式与游戏客户端进行通信。由于所有三种连接类型都使用数据包与游戏客户端进行通信,因此只需与游戏客户端通信,就像始终在专用服务器上运行一样。通过无线局域网(LAN)连接到服务器或者单人游戏时也可以将服务器看做是远程的专用服务器,所以你的游戏客户端不能直接访问服务器实例。
网络简介
首先解决上面展示的示例代码的问题。我们使用数据包与客户端进行通信,所以希望确保仅在服务器上启动操作时才发送数据包。
将数据包发送至游戏客户端
public TypedActionResult<ItemStack> use(World world, PlayerEntity user, Hand hand) { // 确认我们是否是在逻辑服务器上进行操作 if (world.isClient()) return super.use(world, user, hand); // 视线追踪并找到玩家朝向的方块 BlockPos target = ... // 不好的代码:不要这样写! ClientBlockHighlighting.highlightBlock(MinecraftClient.getInstance(), target); return TypedActionResult.success(user.getStackInHand(hand)); }
接下来,我们需要将数据包发送到游戏客户端。首先需要定义一个用于识别数据包的 Identifier
。对于本例,我们的标识符为 tutorial:highlight_block
。为将数据包发送到游戏客户端,需要指定要哪个玩家的游戏客户端接收数据包。由于该操作发生在逻辑服务器上,所以可以将 player
向上强转为 ServerPlayerEntity
。
public class TutorialNetworkingConstants { // 存储数据包的 id 以便后面引用 public static final Identifier HIGHLIGHT_PACKET_ID = Identifier.of("tutorial", "highlight_block"); }
要将数据包发送到玩家,我们使用 ServerPlayerNetworking
中的一些方法。我们使用该类中的以下方法:
public static void send(ServerPlayerEntity player, Identifier channelName, PacketByteBuf buf) { ...
数据包将会被发送到此方法中的玩家。通道名称是你之前决定用来识别数据包的 Identifier
。PacketByteBuf
用于存储数据包的数据。然后我们返回,以通过 buf 将数据写入数据包的有效负载。
由于我们没有向数据包写入任何数据,所以现在,我们将发送带有空有效负载的数据包。可以使用 PacketByteBufs.empty()
创建带有空有效负载的 buf。
.... ServerPlayNetworking.send((ServerPlayerEntity) user, TutorialNetworkingConstants.HIGHLIGHT_PACKET_ID, PacketByteBufs.empty()); return TypedActionResult.success(user.getHandStack(hand)); }
虽然你已经向游戏客户端发送了一个数据包,但游戏客户端无法对数据包做任何事情,因为客户端不知道如何接收数据包。关于游戏客户端接收数据包的信息请参见下方:
在游戏客户端接收数据包
要从游戏客户端上的服务器接收数据包,你的模组需要指定客户端将如何处理收到的数据包。在客户端入口点中,使用 ClientPlayNetworking.registerGlobalReceiver(Identifier channelName, ChannelHandler channelHandler)
为你的数据包注册接收器。
Identifier
应该可你用来将数据包发送到客户端的标识符匹配。你将会实现 ChannelHandler
这个函数型接口,以处理数据包。注意 ChannelHandler
应该是嵌套在 ClientPlayNetworking
中的那个!
下面的例子以匿名函数的形式实现玩家通道处理器:
ClientPlayNetworking.registerGlobalReceiver(TutorialNetworkingConstants.HIGHLIGHT_PACKET_ID, (client, handler, buf, responseSender) -> { ... });
但是,你还不能立即绘制高亮框。这是因为接收器是在网络事件循环中调用的。事件循环在另一个线程上运行,你必须在渲染线程上绘制高亮框。
要绘制高亮框,你需要在游戏客户端上安排任务。这可以由通道处理器提供的 client
字段来完成。通常,你将使用 execute
方法在客户端上运行任务:
ClientPlayNetworking.registerGlobalReceiver(TutorialNetworkingConstants.HIGHLIGHT_PACKET_ID, (client, handler, buf, responseSender) -> { client.execute(() -> { // 此 lambda 中的所有内容都在渲染线程上运行 ClientBlockHighlighting.highlightBlock(client, target); }); });
你可能已经注意到你并不知道要高亮哪个方块。你可以将此数据写入数据包字节 buf。不再在你的物品的 use
方法中向游戏客户端发送 PacketByteBufs.empty()
,而是创建一个新的数据包字节 buf 并将其发送。
PacketByteBuf buf = PacketByteBufs.create();
接下来,您需要将数据写入数据包字节buf。应该注意的是,你必须以和写入数据相同的顺序读取数据。
PacketByteBuf buf = PacketByteBufs.create(); buf.writeBlockPos(target);
之后,你将通过 send
方法发送 buf
字段。
要读取游戏客户端上的方块坐标,你可以使用 PacketByteBuf.readBlockPos()
。
你应该先从网络线程上的数据包中读取所有数据然后再在客户端线程上安排任务。如果你尝试在客户端线程上读取数据,将收到与 ref count 有关的错误。如果一定要在客户端线程上读取数据,则需要 retain()
(保留)这些数据,然后在客户端线程上读取。如果你 retain()
了数据,请确保在不再需要时 release()
(释放)数据。
最后,客户端的处理程序将如下所示:
ClientPlayNetworking.registerGlobalReceiver(TutorialNetworkingConstants.HIGHLIGHT_PACKET_ID, (client, handler, buf, responseSender) -> { // 在事件循环上读取数据包数据 BlockPos target = buf.readBlockPos(); client.execute(() -> { // 此 lambda 中的所有内容都在渲染线程上运行 ClientBlockHighlighting.highlightBlock(client, target); }); });
向服务器发送数据包并在服务器上接收数据包
将数据包发送到服务器并在服务器上接收数据包与在客户端上的操作非常相似。但是,有一些关键的区别。
首先,将数据包发送到服务器是通过 ClientPlayNetworking.send
完成的。在服务器接收数据包与在客户端接收数据包比较类似,使用 ServerPlayNetworking.registerGlobalReceiver(Identifier channelName, ChannelHandler channelHandler)
方法。用于服务器网络通信的 ChannelHandler
也会通过 player
参数传入发送该数据包的 ServerPlayerEntity
(玩家)。
追踪的概念以及为什么只有你看到高亮的方块
现在,高亮魔杖恰当地使用了网络,因此专用服务器不会崩溃。你邀请你的朋友回到服务器上来炫耀高亮魔杖,你使用魔杖,并且该方块也在你的客户端上高亮了,并且服务器没有崩溃。但是,你的朋友没有看到高亮。这是你在此处已有的代码有意为之的。为解决此问题,看一下物品的 use
代码:
public TypedActionResult<ItemStack> use(World world, PlayerEntity user, Hand hand) { // 确认我们是否是在逻辑服务器上进行操作 if (world.isClient()) return super.use(world, user, hand); // 视线追踪并找到玩家朝向的方块 BlockPos target = ... PacketByteBuf buf = PacketByteBufs.create(); buf.writeBlockPos(target); ServerPlayNetworking.send((ServerPlayerEntity) user, TutorialNetworkingConstants.HIGHLIGHT_PACKET_ID, buf); return TypedActionResult.success(user.getHandStack(hand)); }
你可能注意到了,该物品只会将数据包发送给使用了该物品的玩家。为解决此问题,我们可以使用 PlayerLookup
中的实用方法来获取所有可以看到高亮方块的玩家。
因为我们知道高亮会出现在哪个位置,所以我们可以使用 PlayerLookup.tracking(ServerWorld world, BlockPos pos)
来获取所有可以在世界上看到该位置的玩家的集合。然后,您只需在返回的集合中遍历所有玩家,并将数据包发送给每个玩家:
public TypedActionResult<ItemStack> use(World world, PlayerEntity user, Hand hand) { // 确认我们是否是在逻辑服务器上进行操作 if (world.isClient()) return super.use(world, user, hand); // 视线追踪并找到玩家朝向的方块 BlockPos target = ... PacketByteBuf buf = PacketByteBufs.create(); buf.writeBlockPos(target); // 迭代世界上所有追踪位置的玩家,并将数据包发送给每个玩家 for (ServerPlayerEntity player : PlayerLookup.tracking((ServerWorld) world, target)) { ServerPlayNetworking.send(player, TutorialNetworkingConstants.HIGHLIGHT_PACKET_ID, buf); } return TypedActionResult.success(user.getHandStack(hand)); }
这样修改之后,当你使用魔杖时,你的朋友也应该在他们自己的客户端上看到高亮方块。
1.20.5 中的网络通信
自 1.20.5 开始,网络通信的逻辑被大改。在 1.20.5 中,RegistryByteBuf
现在用于游玩阶段网络,你需要定义一个 Payload
。首先,定义一个包含了 BlockPos
的 Payload
:
public record BlockHighlightPayload(BlockPos blockPos) implements CustomPayload { public static final Id<BlockHighlightPayload> ID = new CustomPayload.Id<>(TutorialNetworkingConstants.HIGHLIGHT_PACKET_ID); public static final PacketCodec<PacketByteBuf, BlockHighlightPayload> CODEC = PacketCodec.tuple(BlockPos.PACKET_CODEC, BlockHighlightPayload::blockPos, BlockHighlightPayload::new); // 或者,你也可以这样写: // public static final PacketCodec<PacketByteBuf, BlockHighlightPayload> CODEC = PacketCodec.of((value, buf) -> buf.writeBlockPos(value.blockPos), buf -> new BlockHighlightPayload(buf.readBlockPos())); @Override public Id<? extends CustomPayload> getId() { return ID; } }
然后,像这样注册 receiver:
PayloadTypeRegistry.playS2C().register(BlockHighlightPayload.ID, BlockHighlightPayload.CODEC); ClientPlayNetworking.registerGlobalReceiver(BlockHighlightPayload.ID, (payload, context) -> { context.client().execute(() -> { ClientBlockHighlighting.highlightBlock(client, target); }); });
现在,在服务器一端,你可以像这样把数据包发送给玩家:
public TypedActionResult<ItemStack> use(World world, PlayerEntity user, Hand hand) { if (world.isClient()) return super.use(world, user, hand); // ... for (ServerPlayerEntity player : PlayerLookup.tracking((ServerWorld) world, target)) { ServerPlayNetworking.send(player, new BlockHighlightPayload(blockPos)); } return TypedActionResult.success(user.getHandStack(hand)); }