网络包
与其他教程可能略有不同的是,我们将会使用T88的网络包注解处理器来完成这一部分。
如果你更喜欢使用纯原版内容来编写网络包,Fledge的Boson教程是一个非常不错的选择。不过你需要注意其中使用的是mcp表。
在服务端与客户端
一章提到过,数据并不会自己在两端之间同步,而在MC没有提供对应的同步方法的情况下,我们就需要编写自己的网络包来实现同步。
- 1.20.4
- 1.21
随着Minecraft的改动,NeoForge对网络包系统进行了重构,现在网络包必须实现CustomPacketPayload
接口,而且采用了与之前不同的注册系统。
幸运的是,T88的注解处理器将会为你完成大部分的变更,你也不需要自己实现CustomPacketPayload
(不过这样会使得你只能使用NetWorkHelper
来帮助你把网络包转换为生成的代理网络包类来进行发包)。需要你手动进行的变更将在下文提到。
将你的网络包转换为生成的代理网络包的过程可能会出现问题,你需要在开发时关注日志中有无报错信息。另外这个转换步骤将会消耗一点点性能,你可能需要关注其具体的影响。
需要注意,T88的注解处理器目前只支持play阶段的网络包,暂不支持配置阶段的网络包。
在1.21,Codec得到了大规模应用。T88也加入了对record-codec形式网络包的支持,写出一个网络包变得更加简单:
@NetPacket(modid = SignMeUp.MODID)
public record SetWaypointPacket(String name, String description, BlockPos pos) {
@Codec
public static final StreamCodec<ByteBuf, SetWaypointPacket> STREAM_CODEC = StreamCodec.composite(
ByteBufCodecs.STRING_UTF8,
SetWaypointPacket::name,
ByteBufCodecs.STRING_UTF8,
SetWaypointPacket::description,
BlockPos.STREAM_CODEC,
SetWaypointPacket::pos,
SetWaypointPacket::new
);
@ClientHandler
public void clientHandler(IPayloadContext context){
var waypoint = new Waypoints.WayPoint(name, description, pos.getX(), pos.getY(), pos.getZ());
ConfigHelper.getConfigWrite(Waypoints.class, waypoints -> waypoints.waypoints.add(waypoint));
}
}
不再需要@Encoder
和@Decoder
,@Codec
就能承担起两者的作用。
此处使用了秦千久的K9Tools插件来生 成codec。
Codec是一种更加面向对象的序列化/反序列化工具。你可以在以下两处了解关于codec的更多信息:
同时NetworkHelper
也有一些小改动,将各种发包目标具体化至不同方法。
准备工作
你可以先阅读-5 lib-mod
的使用这个lib-mod
部分,了解更多关于添加依赖的知识。
首先你需要在T88的CurseForge文件页面找到右侧的项目编号Project ID
,它应该始终是663112
。然后在文件列表中找到最新的不带source
字样的jar,将鼠标置于其上,在浏览器的下方便会显示出这个jar文件的链接,链接的最后是它的编号,记下它。
对于正在写这篇教程的我,现在是2023年3月6日,此时最新版本的T88是0.2.34,它对应的t88-0.2.34.jar
编号是4404259
,没有对应的source
jar。对于你而言,一般选择最新版本的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了。
由于我们只在这里使用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
包含两个值,modid
和version
。version
通常不需要你关心,而我强烈建议你给modid
重新指定为你自己的modid,而不是用默认的"t88"
。
随后,(多数情况下)你所有需要传输的内容都应该写在成员变量中,在这里我们只需要一个字符串就足够了。接下来是一个简单的构造方法,我们在其他代码中创建包时会用到。
一个网络包还需要另外三个方法,分别用于把一串字节解码组装成包、把网络包里面的内容打包成一串字节,以及在收到网络包之后执行的动作。他们对应需要的注解分别是@Decoder
、@Encoder
和@Consumer
,不需要任何额外的参数。
很明显,FriendlyByteBuf
提供分别地提供了对应的读写方法,我们只需要简单调用即可。稍加探索你就会发现,它提供的可以编/解码的对象十分多样。
你的@Decoder
和@Encoder
中对各字段的读写顺序应该相同。
你的网络包的encode
和decode
部分不应该有任何副作用。这不仅是出于对网络监视器兼容性的考虑,具有副作用的编解码器是非常糟糕的设计。
最后一个就是负责最终执行的方法了。上面展示的是一个常见的写法,将服务器收到客户端发来的包时需要做的事情写在serverHandler()
里,将客户端收到服务器发来的包时需要做的事情写在clientHandler()
里。在clientHandler()
中,由于需要做的事情往往以Minecraft minecraft = Minecraft.getInstance()
打头,所以我会习惯性地加上@OnlyIn(Dist.CLIENT)
。如果你的包只是单向发送,或许你就可以都写在一个方法里面。
所有需要进行的动作都需要写在context.get().enqueueWork()
里:负责收发网络包的显然不是主线程,你也不能在收到网络包后立即对主线程进行操作。
参数context
值得一探,其中含有许多对你有用的方法。
- 1.19.4
- 1.20.4
Forge将SimpleChannel.consumer
标记为了弃用,其被细分为了consumerMainThread
和consumerNetworkThread
,consumer
也被定向至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();
}
}
在新版网络包系统,不再有一个统一的@Consumer
,取而代之的是分别的@ClientHandler
和@ServerHandler
。这两个方法需要接收一个PlayPayloadContext
对象为形参,并在主线程中执行。现在一个网络包大概像这样:
@NetPacket
public class RecommendOrbCheckPacket {
...
public RecommendOrbCheckPacket(...) {
...
}
@Decoder
public RecommendOrbCheckPacket(FriendlyByteBuf buf) {
...
}
@Encoder
public void write(FriendlyByteBuf buf) {
...
}
@ClientHandler
public void clientHandler(PlayPayloadContext context) {
...
}
@ServerHandler
public void serverHandler(PlayPayloadContext context) {
...
}
}
@Encoder
方法并不需要一定名为write
。注解处理器会为你收拾妥当。
发送网络包
由于服务器和客户端是一对多的关系,从客户端向服务器发包只需要指定发送的内容即可:
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"));
- 1.19.4
- 1.20.4
PacketProxy
已更名为PacketHelper
,并提供几个更简化的常用方法。
PacketHelper
已更名为NetworkHelper
, 不再需要先获取SimpleChannel
,直接调用其中的工具方法即可。
编写完之后
编写完你的网络包之后,你可以选择运行compileJava
进行编译,T88会为你生成所需的ModNetworkRegistry
类。一般情况下,你可以在项目目录的./build/generated/sources/annotationProcessor/java/main/t88/
里找到它。
如果你运行build
并打开打包出的jar文件,你应该会发现ModNetworkRegistry
被放到了你的自定义网络包类同级的generated
包中。这是T88的默认设定位置。
ModNetworkRegistry
很简单:我们在MOD总线上监听FMLCommonSetupEvent
事件,初始化我们需要的SimpleChannel
,指定网络包和处理方法。你完全可以不借助T88来编写自己的网络包——但是你很有可能会迅速地对各类Channel注册感到厌烦。
等等,Channel?
网络包的工作原理是这样的:将一个网络包对象@Encoder
到FriendlyButeBuf
中,通过指定的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度。很容易想到两种实现方法:
- 状态式:向空调发送一个
设为25
的指令; - 增量式:向空调发送一个
-2
的指令。
在游戏环境中,不会哪种办法都会面临一个经典的问题:区块加载。如果服务器发包想要更改某个实体的数据,而这个实体并没有在本地被加载(距离过远或者在另一个维度),那显然此次同步不会成功。
实际场景中可能不止区块加载这一个原因会造成这种问题。
为了解决这个问题,我们很容易想到两个方案:我们可以建立一个缓存,暂时把数据塞在缓存里,则等到实体加载时从缓存中读出数据执行同步;或者隔一时间就发一个包——显然只有状态式才能这么做。
显然,状态式需要时常发包检查同步状况,而增量式可以只在发生变化时执行一次同步,消耗更少的性能。
在这两种情况之间,你需要根据实际情况来选择。如果有一套完整的缓存系统,则可以只用增量式。如果对性能有要求,则可以主要使用增量式,偶尔在合适的时机使用状态式。
如果你的情况不符合以上两种条件,我建议你使用状态式。它虽然消耗略多一些的性能,但和花费同样时间的增量式有更高的可靠性。
一些特殊类的编/解码
为方便起见,在这里先列出FriendlyByteBuf
直接支持的常用类型:
boolean | byte/bytes | char/CharSequence | double | float | int | short/long |
---|---|---|---|---|---|---|
BlockHitResult | BlockPos | ChunkPos | Component(Text) | Date | Enum<?> | ItemStack |
NBT(CompoundTag) | FluidStack | SectionPos | ResourceLocation | UUID |
除此之外,还支持对集合的读写,但你需要自己提供具体的方法。更进一步,如果你可以提供对应的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);
}
你可能注意到,解码时需要指定一个ParticleType
:ModParticleRegistry.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);
...
}
}
- 1.19.4
如果要将ParticleType
转为raw-id
:int id = BuiltInRegistries.PARTICLE_TYPE.getId(particleType)
如果要将raw-id
转为ParticleType
:ParticleType<?> particleType = BuiltInRegistries.PARTICLE_TYPE.byId(target);
对于更多的类型,例如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。
同理,在传输并使用其他数据时,也需要进行必要的检验。