关于网络通信,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 的构造器需要四个参数

  1. 热键的本地化名称:将显示在选项 - 控制中
  2. 热键的类型:可选值为 KEYSYM, SCANCODE, MOUSE. 分别对应键盘,组合键,鼠标
  3. 热键的值:具体取决于类型,这里 InputConstants.KEY_Z 代表键盘的 Z 键
  4. 热键类别的本地化名称:也将显示在选项 - 控制中

接下来我们要为热键赋予功能,根据 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