Skip to main content
Version: 1.21.4

注册 Payload(Payloads)

Payload(有效载荷)是一种用于在客户端与服务端之间发送任意数据的机制。你可以通过 RegisterPayloadHandlersEvent 事件中的 PayloadRegistrar 进行注册。

@SubscribeEvent
public static void register(final RegisterPayloadHandlersEvent event) {
// 设置当前网络版本
final PayloadRegistrar registrar = event.registrar("1");
}

假设我们希望发送如下的数据:

public record MyData(String name, int age) {}

那么我们可以实现 CustomPacketPayload 接口,来创建一个可用于发送和接收该数据的 payload。

public record MyData(String name, int age) implements CustomPacketPayload {

public static final CustomPacketPayload.Type<MyData> TYPE = new CustomPacketPayload.Type<>(ResourceLocation.fromNamespaceAndPath("mymod", "my_data"));

// 每一对元素定义了要编码/解码的元素的流编解码器,以及用于编码该元素的 getter
// 'name' 会作为字符串进行编码和解码
// 'age' 会作为整数进行编码和解码
// 最后一个参数以传入参数顺序接收前面的参数,用于构造 payload 对象
public static final StreamCodec<ByteBuf, MyData> STREAM_CODEC = StreamCodec.composite(
ByteBufCodecs.STRING_UTF8,
MyData::name,
ByteBufCodecs.VAR_INT,
MyData::age,
MyData::new
)

@Override
public CustomPacketPayload.Type<? extends CustomPacketPayload> type() {
return TYPE;
}
}

从上面的示例可以看出,CustomPacketPayload 接口要求我们实现 type 方法。type 方法负责返回该 payload 的唯一标识符。之后,我们还需要一个读取器(reader),以便稍后通过 StreamCodec 注册,用于读写 payload 数据。

最后,我们可以通过 registrar 注册这个 payload:

@SubscribeEvent
public static void register(final RegisterPayloadHandlersEvent event) {
final PayloadRegistrar registrar = event.registrar("1");
registrar.playBidirectional(
MyData.TYPE,
MyData.STREAM_CODEC,
new DirectionalPayloadHandler<>(
ClientPayloadHandler::handleDataOnMain,
ServerPayloadHandler::handleDataOnMain
)
);
}

分析上面的代码,我们可以注意到以下几点:

  • 注册器(registrar)拥有 play* 方法,可以用于注册在游戏运行阶段(play phase)发送的数据载荷(payload)。
    • 代码中未显示的方法还有 configuration*common*;它们同样可以用来注册在配置阶段(configuration phase)发送的数据载荷。common 方法可以同时为配置阶段和运行阶段注册数据载荷。
  • 注册器还提供了 *Bidirectional 方法,可用于注册需要在逻辑服务器(logical server)和逻辑客户端(logical client)之间双向传递的数据载荷。
    • 代码中未显示的方法还有 *ToClient*ToServer;它们分别可以用来只向逻辑客户端或只向逻辑服务器注册数据载荷。
  • 数据载荷的类型(type)被用作该数据载荷的唯一标识符。
  • 流编解码器(stream codec) 用于将数据载荷从网络缓冲区中读取和写入。
  • 数据载荷处理器(payload handler)是在某一逻辑端收到数据载荷时调用的回调函数。
    • 如果使用了 *Bidirectional 方法,可以通过 DirectionalPayloadHandler 分别为每个逻辑端提供独立的数据载荷处理器。

现在我们已经注册了数据载荷,接下来需要实现一个处理器。本例中我们将重点讲解客户端处理器,服务器端的处理器实现方式与其非常相似。

public class ClientPayloadHandler {

public static void handleDataOnMain(final MyData data, final IPayloadContext context) {
// 在主线程中处理数据
blah(data.age());
}
}

这里有几点需要注意:

  • 处理方法会接收到数据载荷和一个上下文对象(contextual object)。
  • 默认情况下,数据载荷的处理方法会在主线程(main thread)上被调用。

如果你需要进行一些资源消耗较大的计算,建议将这些操作放在网络线程(network thread)中进行,避免阻塞主线程。可以在注册数据载荷前,通过 PayloadRegistrar#executesOn 设置 PayloadRegistrarHandlerThreadHandlerThread#NETWORK,实现这一点。

@SubscribeEvent
public static void register(final RegisterPayloadHandlersEvent event) {
final PayloadRegistrar registrar = event.registrar("1")
.executesOn(HandlerThread.NETWORK); // 之后注册的所有数据载荷都将在网络线程中执行
registrar.playBidirectional(
MyData.TYPE,
MyData.STREAM_CODEC,
new DirectionalPayloadHandler<>(
ClientPayloadHandler::handleDataOnNetwork,
ServerPayloadHandler::handleDataOnNetwork
)
);
}
note

所有在调用 executesOn 之后注册的数据载荷,都会保持相同的线程执行环境,直到再次调用 executesOn

PayloadRegistrar registrar = event.registrar("1");

registrar.playBidirectional(...); // 在主线程上
registrar.playBidirectional(...); // 在主线程上

// 配置方法会通过创建一个新的实例来修改 registrar 的状态,
// 因此需要通过存储结果来更新变化
registrar = registrar.executesOn(HandlerThread.NETWORK);

registrar.playBidirectional(...); // 在网络线程上
registrar.playBidirectional(...); // 在网络线程上

registrar = registrar.executesOn(HandlerThread.MAIN);

registrar.playBidirectional(...); // 在主线程上
registrar.playBidirectional(...); // 在主线程上

这里有几点需要注意:

  • 如果你希望在主游戏线程上运行代码,可以使用 enqueueWork 方法将任务提交到主线程。
    • 该方法会返回一个 CompletableFuture,它会在主线程上完成。
    • 注意:因为返回的是一个 CompletableFuture,所以你可以将多个任务串联起来,并且可以在一个地方统一处理异常。
    • 如果你没有在 CompletableFuture 中处理异常,那么异常会被吞掉,你将不会收到任何通知
public class ClientPayloadHandler {

public static void handleDataOnNetwork(final MyData data, final IPayloadContext context) {
// 在网络线程上处理数据
blah(data.name());

// 在主线程上处理数据
context.enqueueWork(() -> {
blah(data.age());
})
.exceptionally(e -> {
// 处理异常
context.disconnect(Component.translatable("my_mod.networking.failed", e.getMessage()));
return null;
});
}
}

有了你自己的自定义数据包(payload),你就可以用它们来通过 配置任务 配置客户端和服务端。

发送数据包(Sending Payloads)

CustomPacketPayload 会通过原版的网络包系统进行发送:向服务器发送时,使用 ServerboundCustomPayloadPacket 封装 payload,向客户端发送时则使用 ClientboundCustomPayloadPacket。发送到客户端的数据包最大只能包含 1 MiB 的数据,而发送到服务器的数据包则必须小于 32 KiB。

所有的数据包都会通过 Connection#send 方法发送,底层做了一定的抽象;但如果你想根据特定条件向多个玩家发送数据包,直接调用这些方法会比较繁琐。因此,PacketDistributor 提供了多种便捷的实现方式来发送数据包。发送到服务器的方法只有一个(sendToServer);但针对客户端,根据需要接收数据包的玩家不同,有多种发送方法可选。

// 客户端代码

// 发送数据到服务器
PacketDistributor.sendToServer(new MyData(...));

// 服务器端代码

// 发送数据给单个玩家(ServerPlayer serverPlayer)
PacketDistributor.sendToPlayer(serverPlayer, new MyData(...));

/// 发送数据给所有追踪该区块的玩家(ServerLevel serverLevel, ChunkPos chunkPos)
PacketDistributor.sendToPlayersTrackingChunk(serverLevel, chunkPos, new MyData(...));

/// 发送数据给所有已连接的玩家
PacketDistributor.sendToAllPlayers(new MyData(...));

更多用法请参考 PacketDistributor 类。

配置 流编解码器