Skip to main content

网络包

与其他教程可能略有不同的是,我们将会使用T88的网络包注解处理器来完成这一部分。

info

如果你更喜欢使用纯原版内容来编写网络包,Fledge的Boson教程是一个非常不错的选择。不过你需要注意其中使用的是mcp表。

服务端与客户端一章提到过,数据并不会自己在两端之间同步,而在MC没有提供对应的同步方法的情况下,我们就需要编写自己的网络包来实现同步。

随着Minecraft的改动,NeoForge对网络包系统进行了重构,现在网络包必须实现CustomPacketPayload接口,而且采用了与之前不同的注册系统。 幸运的是,T88的注解处理器将会为你完成大部分的变更,你也不需要自己实现CustomPacketPayload(不过这样会使得你只能使用NetWorkHelper来帮助你把网络包转换为生成的代理网络包类来进行发包)。需要你手动进行的变更将在下文提到。

info

将你的网络包转换为生成的代理网络包的过程可能会出现问题,你需要在开发时关注日志中有无报错信息。另外这个转换步骤将会消耗一点点性能,你可能需要关注其具体的影响。

caution

需要注意,T88的注解处理器目前只支持play阶段的网络包,暂不支持配置阶段的网络包。


准备工作

info

你可以先阅读-5 lib-mod使用这个lib-mod部分,了解更多关于添加依赖的知识。

首先你需要在T88的CurseForge文件页面找到右侧的项目编号Project ID,它应该始终是663112。然后在文件列表中找到最新的不带source字样的jar,将鼠标置于其上,在浏览器的下方便会显示出这个jar文件的链接,链接的最后是它的编号,记下它。

note

对于正在写这篇教程的我,现在是2023年3月6日,此时最新版本的T88是0.2.34,它对应的t88-0.2.34.jar编号是4404259,没有对应的sourcejar。对于你而言,一般选择最新版本的T88即可。

如果你阅读了lib-mod一节,你就会知道如何在有相同版本号的带source字样的jar时,将它也加入你的依赖之中。

打开你项目的build.gradle,添加

repositories {
maven {
url "https://cursemaven.com"
content {
includeGroup "curse.maven"
}
}
}

dependencies {
minecraft 'net.minecraftforge:forge:1.18.2-40.1.86'

annotationProcessor 'org.spongepowered:mixin:0.8.5:processor'

implementation fg.deobf('curse.maven:t88-663112:4404259')
annotationProcessor 'curse.maven:t88-663112:4404259'
}

刷新gradle项目,你应该就能在外部库中找到T88了。

tip

由于我们只在这里使用T88的注解及其处理器,而网络包注册类生成是在编译阶段就需要完成,故在生产环境进行游戏时并不需要T88。也就是说,我们在这一章不需要向mods.toml里写入依赖要求。

编写网络包

在R6MS中,当玩家点击加入或退出排队时,我们需要发包告知服务器这个变化;当服务器由于某种原因将玩家踢出队列时,客户端也需要及时地知道这个变化。

这是我们的PlayerQueuePack类,它负责完成上述的工作:

@NetPacket(modid = R6Constants.MOD_ID)
public class PlayerQueuePack {
String event;

public PlayerQueuePack(String event) {
this.event = event;
}

@Decoder
public PlayerQueuePack(FriendlyByteBuf buf) {
this.event = buf.readUtf();
}

@Encoder
public void writeToNet(FriendlyByteBuf buf) {
buf.writeUtf(event);
}

@Consumer
public void handler(Supplier<NetworkEvent.Context> context) {
context.get().enqueueWork(
() -> {
if (context.get().getDirection().equals(NetworkDirection.PLAY_TO_SERVER)) {
serverHandler();
} else {
clientHandler();
}
}
);
context.get().setPacketHandled(true);
}

public void serverHandler() {
switch (event){
case "joinQueue":
...
}

@OnlyIn(Dist.CLIENT)
public void clientHandler() {
...
}
}

注解@NetPacket标记了这个类需要在编译时由T88对其生成对应的注册内容。@NetPacket包含两个值,modidversionversion通常不需要你关心,而我强烈建议你给modid重新指定为你自己的modid,而不是用默认的"t88"

随后,(多数情况下)你所有需要传输的内容都应该写在成员变量中,在这里我们只需要一个字符串就足够了。接下来是一个简单的构造方法,我们在其他代码中创建包时会用到。

一个网络包还需要另外三个方法,分别用于把一串字节解码组装成包、把网络包里面的内容打包成一串字节,以及在收到网络包之后执行的动作。他们对应需要的注解分别是@Decoder@Encoder@Consumer,不需要任何额外的参数。

很明显,FriendlyByteBuf提供分别地提供了对应的读写方法,我们只需要简单调用即可。稍加探索你就会发现,它提供的可以编/解码的对象十分多样。

注意

你的@Decoder@Encoder中对各字段的读写顺序应该相同。

danger

你的网络包的encodedecode部分不应该有任何副作用。这不仅是出于对网络监视器兼容性的考虑,具有副作用的编解码器是非常糟糕的设计。

最后一个就是负责最终执行的方法了。上面展示的是一个常见的写法,将服务器收到客户端发来的包时需要做的事情写在serverHandler()里,将客户端收到服务器发来的包时需要做的事情写在clientHandler()里。在clientHandler()中,由于需要做的事情往往以Minecraft minecraft = Minecraft.getInstance()打头,所以我会习惯性地加上@OnlyIn(Dist.CLIENT)。如果你的包只是单向发送,或许你就可以都写在一个方法里面。

所有需要进行的动作都需要写在context.get().enqueueWork()里:负责收发网络包的显然不是主线程,你也不能在收到网络包后立即对主线程进行操作。

参数context值得一探,其中含有许多对你有用的方法。

Forge将SimpleChannel.consumer标记为了弃用,其被细分为了consumerMainThreadconsumerNetworkThreadconsumer也被定向至consumerMainThread。T88默认使用consumerMainThread。所以你不再需要context.get().enqueueWork()来包装你需要执行的的逻辑;也不再需要context.get().setPacketHandled(true);

你的handler方法现在可以简化为这样:

@Consumer
public void handler(Supplier<NetworkEvent.Context> context) {
if (context.get().getDirection().equals(NetworkDirection.PLAY_TO_SERVER)) {
serverHandler();
} else {
clientHandler();
}
}

发送网络包

由于服务器和客户端是一对多的关系,从客户端向服务器发包只需要指定发送的内容即可:

PacketProxy.getChannel(PlayerQueuePack.class).sendToServer(new PlayerQueuePack("joinQueue"));

由于T88将每个网络包对应的SimpleChannel从其网络包类中分离,所以你需要通过PacketProxy.getChannel获取对应的SimpleChannel后进行操作。

而从服务器向客户端发包则略复杂一些。假设我们已经通过某种途径获得了一个ServerPlayer对象,想要向他发包:

PacketProxy.getChannel(PlayerQueuePack.class).send(PacketDistributor.PLAYER.with(() -> serverPlayer), new PlayerQueuePack("kick"));

PacketProxy已更名为PacketHelper,并提供几个更简化的常用方法。

编写完之后

编写完你的网络包之后,你可以选择运行compileJava进行编译,T88会为你生成所需的ModNetworkRegistry类。一般情况下,你可以在项目目录的./build/generated/sources/annotationProcessor/java/main/t88/里找到它。

如果你运行build并打开打包出的jar文件,你应该会发现ModNetworkRegistry被放到了你的自定义网络包类同级的generated包中。这是T88的默认设定位置。

ModNetworkRegistry很简单:我们在MOD总线上监听FMLCommonSetupEvent事件,初始化我们需要的SimpleChannel,指定网络包和处理方法。你完全可以不借助T88来编写自己的网络包——但是你很有可能会迅速地对各类Channel注册感到厌烦。

等等,Channel?

网络包的工作原理是这样的:将一个网络包对象@EncoderFriendlyButeBuf中,通过指定的SimpleChannel发送出去。另一端接收到后,重新@Decoder为网络包对象,并执行@Consumer方法。

显然Channel与Packet之前并不是硬绑定的,所以你可以用一个Channel管理多个网络包,利用version加以区分。不过也显然,这样不太扁平化,而注解处理器的加入也使得偷到的懒不太必要,所以我不推荐这种做法。


奇奇怪怪的注意事项

I need to know if you know what you need to know.

在某种情况下,我们可能要通过数据包向其他玩家广播某个实体的一些事情。直接向服务器或者世界全体玩家广播显然是不合算的,而你可能首先会想到只向一定距离内的玩家发送这个数据包:

player.getLevel().getNearbyPlayers(TargetingConditions.forNonCombat(), player, player.getBoundingBox().inflate(range))
.forEach(p -> NetworkHelper.getChannel(SomePacket.class).send(PacketDistributor.PLAYER.with(() -> (ServerPlayer) p), somepacket));

有一种更好的方式是利用PacketDistributor

NetworkHelper.getChannel(SomePacket.class).send(PacketDistributor.TRACKING_ENTITY.with(() -> serverPlayer), somepacket);

如果你还想要把这个包也发给那个玩家自己,使用TRACKING_ENTITY_AND_SELF即可。

数据同步:状态式和增量式

此处适用一个非常生动的例子:你拿起遥控器,想要把空调的温度从27度调整到25度。很容易想到两种实现方法:

  1. 状态式:向空调发送一个设为25的指令;
  2. 增量式:向空调发送一个-2的指令。

在游戏环境中,不会哪种办法都会面临一个经典的问题:区块加载。如果服务器发包想要更改某个实体的数据,而这个实体并没有在本地被加载(距离过远或者在另一个维度),那显然此次同步不会成功。

tip

实际场景中可能不止区块加载这一个原因会造成这种问题。

为了解决这个问题,我们很容易想到两个方案:我们可以建立一个缓存,暂时把数据塞在缓存里,则等到实体加载时从缓存中读出数据执行同步;或者隔一时间就发一个包——显然只有状态式才能这么做。

显然,状态式需要时常发包检查同步状况,而增量式可以只在发生变化时执行一次同步,消耗更少的性能。

在这两种情况之间,你需要根据实际情况来选择。如果有一套完整的缓存系统,则可以只用增量式。如果对性能有要求,则可以主要使用增量式,偶尔在合适的时机使用状态式。

如果你的情况不符合以上两种条件,我建议你使用状态式。它虽然消耗略多一些的性能,但和花费同样时间的增量式有更高的可靠性。

一些特殊类的编/解码

为方便起见,在这里先列出FriendlyByteBuf直接支持的常用类型:

booleanbyte/byteschar/CharSequencedoublefloatintshort/long
BlockHitResultBlockPosChunkPosComponent(Text)DateEnum<?>ItemStack
NBT(CompoundTag)FluidStackSectionPosResourceLocationUUID

除此之外,还支持对集合的读写,但你需要自己提供具体的方法。更进一步,如果你可以提供对应的Codec,那你可以读写任何你想要的类型。不过,在这里我们主要的目的是介绍一些已经提供的、但不在FriendlyByteBuf中的读写或编解码方法。

ParticleOptions和ParticleType

在你编写你自己的ParticleOptions子类时,其中的writeToNetwork就可以在网络包中作为编码方法。至于解码方法,你的ParticleOptions中应该含有一个Deserializer<MadParticleOption>对象,你就可以用它的fromNetwork来作解码方法。

在MadParticle中的例子如下。

@Decoder
public MadParticlePacket(FriendlyByteBuf buf) {
this.particleOption = MadParticleOption.DESERIALIZER.fromNetwork(ModParticleRegistry.MAD_PARTICLE.get(), buf);
}

@Encoder
public void write(FriendlyByteBuf buf) {
particleOption.writeToNetwork(buf);
}

你可能注意到,解码时需要指定一个ParticleTypeModParticleRegistry.MAD_PARTICLE.get(),如果客户端不能预先知道具体的ParticleType是什么,我们就需要一并传输;而且我们不能直接传输ParticleType对象,而是要借助其注册的raw-id。以这个extinguish中的PreciseParticlePack为例:

public class PreciseParticlePack {
private ParticleOptions particleOption;
...

public PreciseParticlePack(ParticleOptions particleOption,...) {
...
}

public PreciseParticlePack(FriendlyByteBuf buffer) {
ParticleType<?> particleType = Registry.PARTICLE_TYPE.byId(buffer.readInt());
this.particleOption = readParticle(buffer, particleType);\
...
}

private <T extends ParticleOptions> T readParticle(FriendlyByteBuf pBuffer, ParticleType<T> pParticleType) {
return pParticleType.getDeserializer().fromNetwork(pParticleType, pBuffer);
}

public void write(FriendlyByteBuf buffer) {
buffer.writeInt(Registry.PARTICLE_TYPE.getId(particleOption.getType()));
particleOption.writeToNetwork(buffer);
...
}
}

如果要将ParticleType转为raw-idint id = BuiltInRegistries.PARTICLE_TYPE.getId(particleType)

如果要将raw-id转为ParticleTypeParticleType<?> particleType = BuiltInRegistries.PARTICLE_TYPE.byId(target);

tip

对于更多的类型,例如Item或是Block,你也可以使用这样的办法。

另一个办法是使用ResourceLocation而不是RawID。效果类似,不再赘述。

服务器安全问题

了解一些历史的读者大概很容易就能看出这一条从何而来。

在Extinguish中,客户端完成粒子检测之后需要向服务器发包,告知需要被扑灭的位置:

public class PutOutFirePack {
...
public void handler(Supplier<NetworkEvent.Context> context) {
context.get().enqueueWork(
() -> {
if (context.get().getDirection().equals(NetworkDirection.PLAY_TO_SERVER)) {
MinecraftServer minecraftServer = (MinecraftServer) LogicalSidedProvider.WORKQUEUE.get(LogicalSide.SERVER);
ResourceKey<Level> key = ResourceKey.create(Registry.DIMENSION_REGISTRY, dimension);
Level level = minecraftServer.getLevel(key);
if (level != null && level.isLoaded(blockPos)) {
BlockState blockState = level.getBlockState(blockPos);
ServerPlayer player = context.get().getSender();
FireHelper.putOut(level, blockState, this.blockPos, player);
}
} else {
}
}
);
context.get().setPacketHandled(true);
}
}

如上所示,服务器收到位置后,会尝试扑灭那个位置的火焰方块。

很显然的是,因为种种原因——服务器并不能控制客户端发来的坐标是什么样的,甚至不能知道这是不是真正的客户端,我们需要在收到坐标后、进行查询前,先验证目标方块所在的区块是否被加载。如果某个客户端发来的坐标老是跑到天涯海角,那你或许可以考虑顺着网线爬过去把对面打一顿ban掉那个ip。

同理,在传输并使用其他数据时,也需要进行必要的检验。