2024年12月25日

开发小记 聊聊Minecraft 1.20.2和1.20.5/1.20.6出现了一些啸更新的网络API

作者 TheWhiteDog9487

0. 观前说明

如果你不是Minecraft的Fabric平台开发者,那这篇文章、嗯说实话你没必要看,因为你完全听不懂我在说什么。
我不希望浪费你的时间,所以特此描述一下。
当然你就是想凑热闹的话,我肯定是欢迎的ψ(`∇´)ψ
谢谢各位!

1. 开头……吗?

写开头真的是遭老罪了😭。
我最近的文章是不是都没写开头啊(回头
想不到真的想不到,想不到一个好开头😴(撒泼打滚

2. 简述一下,发生肾摸事了

提示:由于我不是每个小版本都会跟进,而大改的那段时间我根本就不知道有这么个事,我也不了解具体情况,所以我下面对于历史的描述来自于Fabric Blog。
有错的话请告诉我,我都会改的
(*^-^*)

众所周知,Minecraft客户端的权力并不是无限大的。
客户端损失的权力全划给了服务器。
比如说,一个玩家的血量有多少,这个是服务器负责管理的事情,客户端就别想着掺合了。
而如果客户端想要修改这些数据,也不是不行,你客户端得主动向服务器报备,由服务器去修改,再把修改过的数据同步给客户端。
这就是开发模组中很重要的一部分,网络通信。
而这部分内容,在1.20.2和1.20.5/1.20.6被大改了一轮搞得面目全非。
而更加灾难性的是Fabric Wiki关于这部分内容的教程并未更新,再加上1.20.5/1.20.6之后强制要求使用新的API,所以你只能对着虚空迁移你的模组、在没有详细教程的情况下。
我花了蛮久时间大致研究清楚了这到底是怎么回事,所以写篇文章记录一下很合理吧?
如果能帮助到其他人当然是最好不过了[]~( ̄▽ ̄)~*

3. 更新之前

在1.20.5/1.20.6之前这部分内容其实不难,我做了一个Demo向你演示在这之前是怎么使用网络通信的。
为了方便理解、简化代码而且保留语义,下面的代码和文字描述有些特性:

  • 代码:
    1. 使用匿名内部类而并不是Lambda表达式
    2. 除非必要否则只修改实现了ModInitializer和ClientModInitializer接口的类
    3. 非必要不新增或改动其他任何文件
    4. 不做多余设计,演示完功能就算完成
  • 语言描述:
    1. 未必精简
    2. 尽力向你解释清楚
    3. 为了2,可能不会使用专有名词,哪怕你实际上知道

下面我代码的功能:

  • 客户端:
    1. 加入服务器时注册一个接收数据包的…容器
    2. 注册的容器在收到来自服务器的数据包之后打印日志
    3. 主动向服务器发送一个数据包
  • 服务器:
    1. 有玩家加入时注册一个接收数据包的…容器
    2. 注册的容器在收到来自客户端的数据包之后向客户端发回一个数据包

下面是实现的代码:

服务器:

package com.example;

import net.fabricmc.api.ModInitializer;

import net.fabricmc.fabric.api.networking.v1.PacketByteBufs;
import net.fabricmc.fabric.api.networking.v1.PacketSender;
import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents;
import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking;
import net.minecraft.network.PacketByteBuf;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.network.ServerPlayNetworkHandler;
import net.minecraft.server.network.ServerPlayerEntity;
import net.minecraft.util.Identifier;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class TemplateMod implements ModInitializer {
	public static final String MOD_ID = "template-mod";

	// This logger is used to write text to the console and the log file.
	// It is considered best practice to use your mod id as the logger's name.
	// That way, it's clear which mod wrote info, warnings, and errors.
	public static final Logger LOGGER = LoggerFactory.getLogger(MOD_ID);

	@Override
	public void onInitialize() {
		// This code runs as soon as Minecraft is in a mod-load-ready state.
		// However, some things (like resources) may still be uninitialized.
		// Proceed with mild caution.

//		LOGGER.info("Hello Fabric world!");

		ServerPlayConnectionEvents.JOIN.register(new ServerPlayConnectionEvents.Join() {
			@Override
			public void onPlayReady(ServerPlayNetworkHandler handler, PacketSender sender, MinecraftServer server) {
				LOGGER.info("有玩家进入世界");

				var PACKET_CHANNEL = new Identifier(MOD_ID, "test");
				var PACKET_BODY = PacketByteBufs.create();

				ServerPlayNetworking.registerGlobalReceiver(PACKET_CHANNEL, new ServerPlayNetworking.PlayChannelHandler() {
					@Override
					public void receive(MinecraftServer server, ServerPlayerEntity player, ServerPlayNetworkHandler handler, PacketByteBuf buf, PacketSender responseSender) {
						LOGGER.info("已接收到来自于客户端的数据包");
						responseSender.sendPacket(PACKET_CHANNEL, PACKET_BODY);
						LOGGER.info("已向客户端发送数据包");
					}
				});
				LOGGER.info("已注册数据包接收器");
			}
		});
	}
}
运行效果

客户端:

package com.example;

import net.fabricmc.api.ClientModInitializer;
import net.fabricmc.fabric.api.client.networking.v1.ClientPlayConnectionEvents;
import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking;
import net.fabricmc.fabric.api.networking.v1.PacketByteBufs;
import net.fabricmc.fabric.api.networking.v1.PacketSender;
import net.minecraft.client.MinecraftClient;
import net.minecraft.client.network.ClientPlayNetworkHandler;
import net.minecraft.network.PacketByteBuf;
import net.minecraft.util.Identifier;

import static com.example.TemplateMod.LOGGER;
import static com.example.TemplateMod.MOD_ID;

public class TemplateModClient implements ClientModInitializer {
	@Override
	public void onInitializeClient() {
		// This entrypoint is suitable for setting up client-specific logic, such as rendering.
		ClientPlayConnectionEvents.JOIN.register(new ClientPlayConnectionEvents.Join() {
			@Override
			public void onPlayReady(ClientPlayNetworkHandler handler, PacketSender sender, MinecraftClient client) {
				LOGGER.info("已加入世界");

				var PACKET_CHANNEL = new Identifier(MOD_ID, "test");

				ClientPlayNetworking.registerGlobalReceiver(PACKET_CHANNEL, new ClientPlayNetworking.PlayChannelHandler() {
					@Override
					public void receive(MinecraftClient client, ClientPlayNetworkHandler handler, PacketByteBuf buf, PacketSender responseSender) {
						LOGGER.info("已接收到来自于服务器的数据包");
					}
				});
				LOGGER.info("已注册数据包接收器");

				var PACKET_BODY = PacketByteBufs.create();
				ClientPlayNetworking.send(PACKET_CHANNEL, PACKET_BODY);
				LOGGER.info("已发送数据包");
			}
		});
	}
}
运行效果

代码看着很多很头疼对不对?我们把无关内容抽离走,只留下最重要的部分。

服务器:

		ServerPlayConnectionEvents.JOIN.register(new ServerPlayConnectionEvents.Join() {
			@Override
			public void onPlayReady(ServerPlayNetworkHandler handler, PacketSender sender, MinecraftServer server) {
				LOGGER.info("有玩家进入世界");

				var PACKET_CHANNEL = new Identifier(MOD_ID, "test");
				var PACKET_BODY = PacketByteBufs.create();

				ServerPlayNetworking.registerGlobalReceiver(PACKET_CHANNEL, new ServerPlayNetworking.PlayChannelHandler() {
					@Override
					public void receive(MinecraftServer server, ServerPlayerEntity player, ServerPlayNetworkHandler handler, PacketByteBuf buf, PacketSender responseSender) {
						LOGGER.info("已接收到来自于客户端的数据包");
						responseSender.sendPacket(PACKET_CHANNEL, PACKET_BODY);
						LOGGER.info("已向客户端发送数据包");
					}
				});
				LOGGER.info("已注册数据包接收器");
			}
		});

客户端:

		ClientPlayConnectionEvents.JOIN.register(new ClientPlayConnectionEvents.Join() {
			@Override
			public void onPlayReady(ClientPlayNetworkHandler handler, PacketSender sender, MinecraftClient client) {
				LOGGER.info("已加入世界");

				var PACKET_CHANNEL = new Identifier(MOD_ID, "test");

				ClientPlayNetworking.registerGlobalReceiver(PACKET_CHANNEL, new ClientPlayNetworking.PlayChannelHandler() {
					@Override
					public void receive(MinecraftClient client, ClientPlayNetworkHandler handler, PacketByteBuf buf, PacketSender responseSender) {
						LOGGER.info("已接收到来自于服务器的数据包");
					}
				});
				LOGGER.info("已注册数据包接收器");

				var PACKET_BODY = PacketByteBufs.create();
				ClientPlayNetworking.send(PACKET_CHANNEL, PACKET_BODY);
				LOGGER.info("已发送数据包");
			}
		});

这个代码配色加上自动换行…预览起来效果不好,但是关掉自动换行效果更差。
各位要不复制到自己的记事本啦VSCode啦看一下,真不多了这点\^o^/

好了说回正题,现在看起来逻辑还算是蛮简单的。
要发送一个数据包,你需要一个作为信道名称的ID和一个数据包体。
ID直接对Identifier类使用new指令就可以了。但要注意发给一个信道的数据包只有监听同一个信道的接收器才能收到。
想想收音机的使用,这点应该不难理解。
数据包体在需要的时候直接现场PacketByteBufs.create()就能获得一个,然后你可以对这段指令的返回值使用一些函数往里写内容,写完之后发出去就完事。
这个流程不复杂吧?
但是在1.20.5/1.20.6之后,一切都变了……

4. 更新之后

为了向你确切演示这次的更新,我稍微修改一下功能。
上面的服务器和客户端确实交换了数据包,但里面什么都没有。
我这次把新加入玩家的UUID和一个随机生成的数字塞进数据包里,在客户端和服务器里打印出来。
并且顺序反过来,由客户端发送数据,服务器应答即可。

服务器:

package com.example;

import net.fabricmc.api.ModInitializer;

import net.fabricmc.fabric.api.networking.v1.PacketSender;
import net.fabricmc.fabric.api.networking.v1.PayloadTypeRegistry;
import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents;
import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.network.ServerPlayNetworkHandler;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import static com.example.PlayerPayload.PACKET_CODEC;
import static com.example.PlayerPayload.PACKET_ID;

public class TemplateMod implements ModInitializer {
	public static final String MOD_ID = "template-mod";

	// This logger is used to write text to the console and the log file.
	// It is considered best practice to use your mod id as the logger's name.
	// That way, it's clear which mod wrote info, warnings, and errors.
	public static final Logger LOGGER = LoggerFactory.getLogger(MOD_ID);

	@Override
	public void onInitialize() {
		// This code runs as soon as Minecraft is in a mod-load-ready state.
		// However, some things (like resources) may still be uninitialized.
		// Proceed with mild caution.

//		LOGGER.info("Hello Fabric world!");

		PayloadTypeRegistry.playS2C().register(PACKET_ID, PACKET_CODEC);
		LOGGER.info("已为自定义数据包的S2C方向类型注册编解码器");
		PayloadTypeRegistry.playC2S().register(PACKET_ID, PACKET_CODEC);
		LOGGER.info("已为自定义数据包的C2S方向类型注册编解码器");
		ServerPlayConnectionEvents.JOIN.register(new ServerPlayConnectionEvents.Join() {
			@Override
			public void onPlayReady(ServerPlayNetworkHandler serverPlayNetworkHandler, PacketSender packetSender, MinecraftServer minecraftServer) {
				LOGGER.info("有玩家加入服务器");

				ServerPlayNetworking.registerGlobalReceiver(PACKET_ID, new ServerPlayNetworking.PlayPayloadHandler<PlayerPayload>() {
					@Override
					public void receive(PlayerPayload payload, ServerPlayNetworking.Context context) {
						LOGGER.info("接收到来自客户端的数据包");

						var uuid = payload.uuid();
						var randomInt = payload.randomInt();

						LOGGER.info("UUID: " + uuid);
						LOGGER.info("随机数: " + randomInt);

						context.responseSender().sendPacket(payload);
						LOGGER.info("已向客户端发送应答数据包");
					}
				});
				LOGGER.info("已为数据包注册接收器");
			}
		});
	}
}
运行效果

客户端:

package com.example;

import net.fabricmc.api.ClientModInitializer;
import net.fabricmc.fabric.api.client.networking.v1.ClientPlayConnectionEvents;
import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking;
import net.fabricmc.fabric.api.networking.v1.PacketSender;
import net.fabricmc.fabric.api.networking.v1.PayloadTypeRegistry;
import net.minecraft.client.MinecraftClient;
import net.minecraft.client.network.ClientPlayNetworkHandler;
import net.minecraft.network.packet.CustomPayload;

import java.util.Random;

import static com.example.PlayerPayload.PACKET_CODEC;
import static com.example.PlayerPayload.PACKET_ID;
import static com.example.TemplateMod.LOGGER;

public class TemplateModClient implements ClientModInitializer {
	@Override
	public void onInitializeClient() {
		// This entrypoint is suitable for setting up client-specific logic, such as rendering.
		ClientPlayConnectionEvents.JOIN.register(new ClientPlayConnectionEvents.Join() {
			@Override
			public void onPlayReady(ClientPlayNetworkHandler clientPlayNetworkHandler, PacketSender packetSender, MinecraftClient minecraftClient) {
				LOGGER.info("已加入世界");

				var uuid = minecraftClient.player.getUuid();
				var RANDOM = new Random();
				var randomInt = RANDOM.nextInt();
				var packet = new PlayerPayload(uuid, randomInt);

				ClientPlayNetworking.registerGlobalReceiver(PACKET_ID, new ClientPlayNetworking.PlayPayloadHandler<PlayerPayload>() {
					@Override
					public void receive(PlayerPayload payload, ClientPlayNetworking.Context context) {
						LOGGER.info("收到服务器应答");

						var uuid = payload.uuid();
						var randomInt = payload.randomInt();

						LOGGER.info("UUID: " + uuid);
						LOGGER.info("随机数: " + randomInt);
					}
				});
				LOGGER.info("已为数据包注册接收器");

				ClientPlayNetworking.send(packet);
				LOGGER.info("已向服务器发送数据包");
			}
		});
	}
}
运行效果

这次多了一个PlayerPayload.java:

package com.example;

import net.minecraft.network.PacketByteBuf;
import net.minecraft.network.codec.PacketCodec;
import net.minecraft.network.codec.PacketDecoder;
import net.minecraft.network.codec.ValueFirstEncoder;
import net.minecraft.network.packet.CustomPayload;
import net.minecraft.util.Identifier;

import java.util.UUID;

import static com.example.TemplateMod.MOD_ID;

public record PlayerPayload(UUID uuid, Integer randomInt) implements CustomPayload {
    static Id<PlayerPayload> PACKET_ID = new Id<>( Identifier.of(MOD_ID ,"test") );
    static PacketCodec<PacketByteBuf, PlayerPayload> PACKET_CODEC = PacketCodec.of(new ValueFirstEncoder<PacketByteBuf, PlayerPayload>() {
        @Override
        public void encode(PlayerPayload value, PacketByteBuf buf) {
            buf.writeUuid(value.uuid);
            buf.writeInt(value.randomInt);
        }
    }, new PacketDecoder<PacketByteBuf, PlayerPayload>() {
        @Override
        public PlayerPayload decode(PacketByteBuf buf) {
            var uuid = buf.readUuid();
            var randomInt = buf.readInt();
            return new PlayerPayload(uuid, randomInt);
        }
    });

    @Override
    public Id<? extends CustomPayload> getId() {
        return PACKET_ID;
    }
}

简化一下:

服务器:

		PayloadTypeRegistry.playS2C().register(PACKET_ID, PACKET_CODEC);
		LOGGER.info("已为自定义数据包的S2C方向类型注册编解码器");
		PayloadTypeRegistry.playC2S().register(PACKET_ID, PACKET_CODEC);
		LOGGER.info("已为自定义数据包的C2S方向类型注册编解码器");
		ServerPlayConnectionEvents.JOIN.register(new ServerPlayConnectionEvents.Join() {
			@Override
			public void onPlayReady(ServerPlayNetworkHandler serverPlayNetworkHandler, PacketSender packetSender, MinecraftServer minecraftServer) {
				LOGGER.info("有玩家加入服务器");

				ServerPlayNetworking.registerGlobalReceiver(PACKET_ID, new ServerPlayNetworking.PlayPayloadHandler<PlayerPayload>() {
					@Override
					public void receive(PlayerPayload payload, ServerPlayNetworking.Context context) {
						LOGGER.info("接收到来自客户端的数据包");

						var uuid = payload.uuid();
						var randomInt = payload.randomInt();

						LOGGER.info("UUID: " + uuid);
						LOGGER.info("随机数: " + randomInt);

						context.responseSender().sendPacket(payload);
						LOGGER.info("已向客户端发送应答数据包");
					}
				});
				LOGGER.info("已为数据包注册接收器");
			}
		});

客户端:

		ClientPlayConnectionEvents.JOIN.register(new ClientPlayConnectionEvents.Join() {
			@Override
			public void onPlayReady(ClientPlayNetworkHandler clientPlayNetworkHandler, PacketSender packetSender, MinecraftClient minecraftClient) {
				LOGGER.info("已加入世界");

				var uuid = minecraftClient.player.getUuid();
				var RANDOM = new Random();
				var randomInt = RANDOM.nextInt();
				var packet = new PlayerPayload(uuid, randomInt);

				ClientPlayNetworking.registerGlobalReceiver(PACKET_ID, new ClientPlayNetworking.PlayPayloadHandler<PlayerPayload>() {
					@Override
					public void receive(PlayerPayload payload, ClientPlayNetworking.Context context) {
						LOGGER.info("收到服务器应答");

						var uuid = payload.uuid();
						var randomInt = payload.randomInt();

						LOGGER.info("UUID: " + uuid);
						LOGGER.info("随机数: " + randomInt);
					}
				});
				LOGGER.info("已为数据包注册接收器");

				ClientPlayNetworking.send(packet);
				LOGGER.info("已向服务器发送数据包");
			}
		});

PlayerPayload.java:

public record PlayerPayload(UUID uuid, Integer randomInt) implements CustomPayload {
    static Id<PlayerPayload> PACKET_ID = new Id<>( Identifier.of(MOD_ID ,"test") );
    static PacketCodec<PacketByteBuf, PlayerPayload> PACKET_CODEC = PacketCodec.of(new ValueFirstEncoder<PacketByteBuf, PlayerPayload>() {
        @Override
        public void encode(PlayerPayload value, PacketByteBuf buf) {
            buf.writeUuid(value.uuid);
            buf.writeInt(value.randomInt);
        }
    }, new PacketDecoder<PacketByteBuf, PlayerPayload>() {
        @Override
        public PlayerPayload decode(PacketByteBuf buf) {
            var uuid = buf.readUuid();
            var randomInt = buf.readInt();
            return new PlayerPayload(uuid, randomInt);
        }
    });

    @Override
    public Id<? extends CustomPayload> getId() {
        return PACKET_ID;
    }
}

看简化版本的话你可能觉得区别不是很大。
同样的ServerPlayConnectionEvents.JOIN.register
同样的ClientPlayConnectionEvents.JOIN.register
收到包之后自定义处理程序的类和实现函数的签名有些变化但还算能看得懂的水平。
但最麻烦的不是这些,而是新的那个文件。

在新版本中,你需要为每一种特定的数据包结构创建一个Record类来存放这种结构需要的东西,比如ID、序列化和反序列化器、构造函数等(这些等下都会说到)。
这个类必须实现CustomPayload接口,其他没要求。
ID和CODEC(也就是序列化和反序列化器)在不在里边不重要,但是放在里面日后会很好用,不会到处去找找不到,不是么?
构造函数直接塞进类名后面的圆括号里,参数顺序和括号内顺序一致。
ID直接给一个Identifier类的实例就可以。
而CODEC是整个包里最重要的东西。

CODEC决定了你自己的数据包类如何和PacketByteBuf互相转换,这当然就是你自己实现的事情了。
编码器的函数签名是public void encode(PlayerPayload value, PacketByteBuf buf)
什么意思呢?就是说你不用管在网络上传输的PacketByteBuf实例从哪来,现成的给你了。你只需要负责处理怎么把PlayerPayload类的对象value里的数据全部写入到buf里即可。
解码器的函数签名是public PlayerPayload decode(PacketByteBuf buf)
给了你一个已经塞满了数据的buf,你自己想办法把数据拿出来,最后构建一个PlayerPayload类的实例。
这些都做完之后,你还需要使用PayloadTypeRegistry.playS2C().registerPayloadTypeRegistry.playC2S().register对你的CODEC进行注册,不注册是不能用嘀。
这两个具体用哪个取决于数据包方向。如果你实在不知道那就直接在main入口点里把两个都打上即可。用不上不重要、需要用的时候没有那就问题大了。

经过这么一通折腾,最后的结果就是,你发出和接收到的数据包都是你自己定义的PlayerPayload类型,但是在网络上传输的却是PacketByteBuf,这两块隔离开了。

所以最大的变化其实就是每种自定义数据包现在都必须预先设计好数据包的结构了。
以前可以在注册接收器的时候现场写,一边拿这个PacketByteBuf嘎嘎开写另一边抱着PacketByteBuf就是一顿乱读。
现在不仅是要预先把结构定死,而且要把CODEC的逻辑写好,把整个流程锁死避免后续出问题。
确实是比以前麻烦得多但也是有好处的,毕竟泛型约束就摆在那,你引用一个不存在的东西直接就连编译都过不去,是能减少一些问题。

5. 结束了

现在往回看吧,这更新确实动静不小但逻辑还是理得清的。
只是Fabric Wiki和Fabric Docs上都没有更新新版本的教程,导致我这种初级开发者很痛苦,对着空气迁移。
Fabric Blog上确实会说每个版本的更新细节特别是破坏性更新,但是那主要侧重于改了什么,而不是一个完整的可复现的例子。你就是勉强照着那说明改完,要是出了问题也不知道咋办。
脑壳疼只能说。

最后把一些各位可能需要的资料放在下面。

Fabric for Minecraft 1.20.5 & 1.20.6 | Fabric
Fabric for Minecraft 1.20.2 | Fabric
Fabric API guide: Migrating to packet object-based API
网络通信 [Fabric Wiki]
监听事件 [Fabric Wiki]
Codec | Fabric 文档
事件 | Fabric 文档