关于网络通信,ArchitecturyAPI 提供了类似 Fabric 的 Packet 和类似 Forge 的 Channel 这样两种解决方案,这里笔者将带领读者以两个具体实例切入这两种解决方案,读者可以实际感受后选择自己喜欢的方式来进行网络通信.
# 使用 NetworkManager 进行网络通信
本节将使用类似 Fabric 的 NetworkManager 来写一个处理客户端鼠标滚动事件的实例。这里笔者给上文我们提到的经验储存器添加了一个小功能,可以让玩家用 Shift + 滚动来调整单次经验的存取量.
很显然,我们的需求涉及一个 C2S 的网络包请求。熟悉模组开发的读者应该清楚,滚轮事件是由客户端发出的,其产生的影响 (这里是改变经验储存器的一个 NBT 值) 是体现在逻辑服务端的。一个携带数据的网络包需要从玩家的客户端发出,到达逻辑服务器产生影响.
按照需求,我们在 trou.arch 下创建 network 包,并且创建 ModPackets 类
位于common: trou/arch/network/ModPackets.java | |
public class ModPackets { | |
public static final ResourceLocation EXP_CONTAINER_MODE = new ResourceLocation("arch", "exp_container_change_mode"); | |
public static void register() { | |
NetworkManager.registerReceiver(NetworkManager.Side.C2S, EXP_CONTAINER_MODE, (buf, context) -> { | |
Player player = context.getPlayer(); | |
int deltaMode = buf.readInt(); | |
ItemStack stack = player.getItemInHand(InteractionHand.MAIN_HAND); | |
ItemExpContainer.raiseMode(stack, deltaMode); | |
}); | |
} | |
public static void sendExpContainerMousePacket(int deltaMode) { | |
FriendlyByteBuf buf = new FriendlyByteBuf(Unpooled.buffer()); | |
buf.writeInt(deltaMode); | |
NetworkManager.sendToServer(EXP_CONTAINER_MODE, buf); | |
} | |
} |
FriendlyByteBuf 是针对 ByteBuf 的一个封装,其中包含了常用的 Minecraft 对象的序列化和反序列化,读者需要时可以自行查看
需要注意的是,由于 ByteBuf 的特性,数据被写入的顺序和读取的顺序必须一致
显然,我们调用了 NetworkManager 提供的方法并且完成了包的发送和接收,包的数据由一个 ByteBuf 携带,相信读者已经对这种模式不陌生了.
注册包的接收端时需要提供 Side, 由于我们是由逻辑客户端发向逻辑服务端,所以我们选择 Side.C2S
context 包含了一些随包携带的常用参数,包含发起的玩家和 Environment, 方便我们直接进行使用和判断.
最后,不要忘记在主类中调用我们 ModPackets 的 register 方法
# 处理 raiseMode 方法
接下来我们要处理逻辑服务端触发的 raiseMode 方法,我们希望通过这个方法改变手中经验储存器的单次存取量.
位于common: trou/arch/item/ItemExpContainer.java | |
public static void raiseMode(ItemStack stack, int value) { | |
if (!(stack.getItem() instanceof ItemExpContainer)) return; | |
CompoundTag tag = stack.hasTag() ? stack.getTag() : new CompoundTag(); | |
assert tag != null; | |
int newValue = Math.max(tag.getInt("mode") + value, 0); // 限制不为负 | |
tag.putInt("mode", newValue); | |
} |
相信不用笔者过多赘述,读者可以理解其中的逻辑.
我们还需要修改存取经验时的逻辑,使其按照我们设定的数值进行存取
位于common: trou/arch/item/ItemExpContainer.java | |
@Override | |
public InteractionResultHolder<ItemStack> use(@NotNull Level level, @NotNull Player player, @NotNull InteractionHand usedHand) { | |
ItemStack stack = player.getItemInHand(usedHand); | |
if (level.isClientSide || usedHand == InteractionHand.OFF_HAND) return InteractionResultHolder.fail(stack); | |
CompoundTag tag = stack.hasTag() ? stack.getTag() : new CompoundTag(); | |
assert tag != null; | |
int mode = tag.getInt("mode"); | |
if (player.isShiftKeyDown()) { | |
int storedExp = tag.getInt("exp"); | |
int amount = Math.min(mode, storedExp); | |
player.giveExperiencePoints(amount); | |
tag.putInt("exp", storedExp - amount); | |
} else { | |
int ownedExp = player.totalExperience; | |
int cost = Math.min(ownedExp, mode); | |
player.giveExperiencePoints(-cost); | |
tag.putInt("exp", tag.getInt("exp") + cost); | |
} | |
stack.setTag(tag); | |
return InteractionResultHolder.sidedSuccess(stack, level.isClientSide()); | |
} |
最后使单次存取量可以在 Tooltip 显示,并添加相关 i18n 词条
<pre class="language-java"><code class="lang-java"> 位于 common: trou/arch/item/ItemExpContainer.java
<strong>public static void appendTooltip(ItemStack stack, List<Component> lines, TooltipFlag flag) {
</strong> if (stack.getItem() instanceof ItemExpContainer) {
CompoundTag tag = stack.hasTag() ? stack.getTag() : new CompoundTag();
assert tag != null;
lines.add(new TranslatableComponent("tooltip.exp_container", tag.getInt("exp")));
lines.add(new TranslatableComponent("tooltip.exp_container_mode", tag.getInt("mode")));
}
}
</code></pre>
# 监听鼠标滚动事件
接下来我们来完成监听鼠标滚轮的相关事件,相信读过上一节的读者已经对此有头绪了。经过寻找,我们命中了 ClientRawInputEvent.MOUSE_SCROLLED 事件,这个事件会在玩家进入世界后滚动滚轮触发,并且在打开 GUI 等界面时不会触发,完美符合我们的需求.
修改 ModEvents 类来监听这个事件,并且在 ItemExpContainer 类中编写事件处理器
位于common: trou/arch/object/ModEvents.java | |
public class ModEvents { | |
public static void register() { | |
ClientTooltipEvent.ITEM.register(ItemExpContainer::appendTooltip); | |
ClientRawInputEvent.MOUSE_SCROLLED.register(ItemExpContainer::mouseScroll); | |
} | |
} |
显然 ClientRawInputEvent 只会在客户端进入世界后触发,我们可以直接用 minecraft.player 来拿到非空的本地玩家
位于common: trou/arch/item/ItemExpContainer.java | |
// 向上滚动 v = 1.0 向下滚动 v = -1.0 | |
public static EventResult mouseScroll(Minecraft minecraft, double v) { | |
assert minecraft.player != null; // 断定 minecraft.player 不为空 | |
ItemStack heldItem = minecraft.player.getItemInHand(InteractionHand.MAIN_HAND); | |
if (heldItem.getItem() instanceof ItemExpContainer) { | |
if (minecraft.player.isShiftKeyDown()) { | |
ModPackets.sendExpContainerMousePacket((int) v); | |
return EventResult.interruptFalse(); | |
} | |
} | |
return EventResult.pass(); | |
} |
EventResult.interruptFalse () 会阻碍接下来滚轮的滚动,使事件冒泡停止
EventResult.pass () 不会影响接下来滚轮的滚动
到这里,我们的功能已经写好了,读者可以启动游戏验证我们的代码,确保其在 Forge 和 Fabric 均能正常使用
<figure><img src="https://s2.loli.net/2023/01/29/gtjy83Q7NnvPMoI.png"alt=""><figcaption><p > 经验储存器 </p></figcaption></figure>
# 使用 NetworkChannel 进行网络通信
本节我们将使用类似 Forge 的 NetworkChannel 进行网络通信,做出一个点击热键即可将储存器中的经验丢给盯着的玩家的功能
NetworkChannel 的核心概念是 Channel 和 Message, 当网络通信时,我们的视觉效果是 Message 在 Channel 中传递,显然,丢经验这个动作应该属于一条 Message
所以我们在 network 包中创建 MessageThrowExp 类,来诠释丢经验这条消息
位于common: trou/arch/network/MessageThrowExp.java | |
public class MessageThrowExp { | |
public final UUID targetPlayerUUID; | |
// 收到消息时,会调用这个方法来从 buf 中取出数据 | |
public MessageThrowExp(FriendlyByteBuf buf) { | |
this(buf.readUUID()); | |
} | |
// 创建消息时提供数据的构造器 | |
public MessageThrowExp(UUID targetPlayerUUID) { | |
this.targetPlayerUUID = targetPlayerUUID; | |
} | |
// 发送消息时将数据写入 buf 的方法 | |
public void encode(FriendlyByteBuf buf) { | |
buf.writeUUID(targetPlayerUUID); | |
} | |
// 收到消息后,调用的处理事件 | |
public void apply(Supplier<NetworkManager.PacketContext> contextSupplier) { | |
Player self = contextSupplier.get().getPlayer(); | |
Player target = self.level.getPlayerByUUID(targetPlayerUUID); | |
if (target == null) return; | |
ItemStack stack = self.getItemInHand(InteractionHand.MAIN_HAND); | |
ItemExpContainer.doThrow(stack, target); | |
} | |
} |
请永远使用 UUID 来作为玩家的标识符,而不要传递 Player 对象或者玩家的名字
显然,这里我们要承载的数据是指向的玩家的 UUID, 我们需要编写一个构造器来提供成员变量的值,并且在 encode 时将值编码到 ByteBuf 中
apply 方法会在 ByteBuf 的值序列化到实例中后被逻辑服务器调用,这里可以处理我们的逻辑。与上文中的 context 相类似,contextSupplier 会提供 Player, Environment 等可能用到的上下文对象.
完成了 Message, 接下来我们需要定义并注册承载它的 Channel, 在 network 包下创建 ModChannels 类
位于common: trou/arch/network/ModChannels.java | |
public class ModChannels { | |
public static final NetworkChannel CHANNEL = NetworkChannel.create(new ResourceLocation("arch", "exp_container")); | |
public static void register() { | |
CHANNEL.register(MessageThrowExp.class, MessageThrowExp::encode, MessageThrowExp::new, MessageThrowExp::apply); | |
} | |
} |
根据读者对 Channel 的不同理解,你可以在一个 Mod 使用多个 Channel 承载不同功能,也可以只使用一个 Channel 来承载该 Mod 所有的 Message, 这只是设计模式的差别,读者可以根据自己的喜好来决定.
可以看到这里笔者创建的 Channel 的标识符中提到了 exp_container, 即为经验储存器创建了一个单独的 Channel
最后,我们需要在主类中调用 ModChannels.register () 方法
# 处理 doThrow 方法
相信这里不必笔者多说,读者应该已经想出了相关逻辑
位于common: trou/arch/item/ItemExpContainer.java | |
public static void doThrow(ItemStack stack, Player target) { | |
if (!(stack.getItem() instanceof ItemExpContainer)) return; | |
CompoundTag tag = stack.hasTag() ? stack.getTag() : new CompoundTag(); | |
assert tag != null; | |
int storedExp = tag.getInt("exp"); | |
int mode = tag.getInt("mode"); | |
int amount = Math.min(mode, storedExp); | |
target.giveExperiencePoints(amount); | |
tag.putInt("exp", storedExp - amount); | |
} |
当执行这个方法的时候,会从经验提取器中提取指定量的经验,给予目标玩家
# 添加一个热键
ArchitecturyAPI 为我们提供了通用的添加热键的方式.
创建 trou.arch.client 包,并创建 ModKeys 类,创建我们的 KeyMapping
位于common: trou/arch/client/ModKeys.java | |
public class ModKeys { | |
public static final KeyMapping THROW_EXP = new KeyMapping("key.arch.throw_exp", InputConstants.Type.KEYSYM, InputConstants.KEY_Z, "category.arch"); | |
public static void register() { | |
KeyMappingRegistry.register(THROW_EXP); | |
} | |
} |
KeyMapping 的构造器需要四个参数
- 热键的本地化名称:将显示在选项 - 控制中
- 热键的类型:可选值为 KEYSYM, SCANCODE, MOUSE. 分别对应键盘,组合键,鼠标
- 热键的值:具体取决于类型,这里 InputConstants.KEY_Z 代表键盘的 Z 键
- 热键类别的本地化名称:也将显示在选项 - 控制中
接下来我们要为热键赋予功能,根据 ArchitecturyAPI 文档中的介绍,我们要监听 ClientTickEvent, 并且使用一个 while 来判断热键是否被按下.
<figure><img src="https://s2.loli.net/2023/01/29/argPSelXFoEHBzq.png"alt=""><figcaption><p>ArchitecturyAPI 文档 </p></figcaption></figure>
这里我们在 ModEvents 中监听事件,并且编写事件处理器
位于common: trou/arch/object/ModEvents.java | |
public class ModEvents { | |
public static void register() { | |
... | |
ClientTickEvent.CLIENT_POST.register(ModKeys::handleThrowExpKey); | |
} | |
} | |
位于位于common: trou/arch/client/ModKeys.java | |
public static void handleThrowExpKey(Minecraft minecraft) { | |
while (THROW_EXP.consumeClick()) { | |
if (minecraft.player == null) return; | |
//minecraft.crosshairPickEntity 可以获取到玩家光标瞄准的实体 | |
if (minecraft.crosshairPickEntity instanceof Player) { | |
UUID playerUUID = minecraft.crosshairPickEntity.getUUID(); | |
// Channel 提供了发送 Message 的几个方法 | |
// 这里使用了我们创建的 Message 的构造器来提供 playerUUID 数据 | |
ModChannels.CHANNEL.sendToServer(new MessageThrowExp(playerUUID)); | |
} | |
} | |
} |
最后,我们在主类中调用 ModKeys.register 方法来注册热键,为了防止问题,我们让热键在事件之前注册
此时进入游戏,你应该能看到添加的热键了,并且能够使用热键给玩家丢经验了
<figure><img src="https://s2.loli.net/2023/01/29/cMLXQseYhElibv3.png" alt=""><figcaption></figcaption></figure>
# 总结
阅读上文的代码后,你的主类应该是这样的
位于common: trou/arch/Arch.java | |
public class Arch { | |
public static final String MOD_ID = "arch"; | |
public static void init() { | |
System.out.println("Hello Architectury!"); | |
Storyteller.tellStory(); | |
ModItems.register(); | |
ModKeys.register(); | |
ModEvents.register(); | |
ModPackets.register(); | |
ModChannels.register(); | |
} | |
} |
本章我们主要介绍了两种网络通信的方式,使用 NetworkManager 较为简便,使用 NetworkChannel 较为灵活,这里读者可以根据自己的需求来选择.
如果你对 Fabric 熟悉,或需求较为单一,推荐使用 NetworkManager
如果你对 Forge 熟悉,或需求较为复杂,希望可读性强一些,推荐使用 NetworkChannel