# 地狱放水?吟唱魔法?储物工作台?人生苦短,我要 Mixin


Mixin 以其易用性与强大性,逐渐取代了传统意义上的 CoreMod 编写流程,这使得将自己的代码注入到 Minecraft 的内部变得十分容易且便利。这里笔者将从三个从浅入深的例子入手,带领读者快速入门 Mixin, 欣赏 Minecraft 源码的注入艺术

本文推荐使用开发环境:

开发工具: IntelliJ IDEA with Minecraft Devlopment Plugin

开发环境: Fabric Loader 0.11.1 with fabric-api 0.30.0

Mapping 版本: Yarn-1.16.5 build.4:v2

游戏版本: Minecraft 1.16.5 with Fabric

本文的受众对象:

  • 对 Minecraft 有着基本认识和了解的读者

  • 曾经有进行过 Fabric 或 Forge 开发的读者

  • 有独立阅读相关文档,解决问题能力的读者

  • 对 Mixin 的工作原理,使用方式简单了解的读者


# 一、揭开 Minecraft 的面纱 - 水桶美学

# 1. 慧眼观察

本节,我们将以一个十分经典的需求:在地狱放水,打开读者的思路,培养读者阅读源码的能力,并且对 Mixin 建立基本的认识.

相信读者在以往的开发过程中常常会不经意间用到 IDEA 的内置反编译功能,当我们对某一方法或类抱有疑问时,我们常常会 Shift 左击来查看某个原版方法或类的定义。如果我们试图对我们查看到的源码进行更改时,IDEA 会提示我们,该文件是只读的,我们深切的知道,已经编译到 class 的文件是不能被直接修改的.

但正是 Mixin 的出现,使这种更改成为了可能,我们可以利用一些表达方式来在 class 文件被 Java 虚拟机执行前修改它,所以我们的工作就需要先利用 IDEA 的反编译功能定位代码,之后用 Mixin 进行修改.

说到这里,相信聪明的读者已经对我们的定位工作有了头绪,很容易想到,地狱不能放水的原因就出在我们用水桶在地狱放置的一瞬间,所以我们只需在水桶交互的实现方法中找到这部分就可以了.

这里我们使用 IDEA 的查找功能,双击 Shift, 我们可以打开一个快速搜索框

这里我们搜索水桶 Bucket, 我们可以找到原版 Item 包下的 BucketItem 类,这里要注意的是,我们要选中 Include non-project items. 不然的话 Minecraft 包内的方法是没办法查找到的

这里我们直接双击,让 IDEA 用内置的反编译器来查看 BucketItem 类的源码

相信细心的读者很快就能找到对应的条件判断语句,我们在 placeFluid 方法中找到了我们想要操作的目标,我们发现,这与我们印象中地狱放水的现象相似 (先播放桶倒水的声音,之后在生成烟的粒子效果)

这里 Dimension, Ultrawarm 等字样引起了我们的注意,我们进一步向下查阅发现,这个 isUltrawarm 方法返回了成员变量 ultrawarm.

不难猜想,这个成员应该是 DimensionType 类初始化时传入的,而代表地狱的 DimensionType 创建时参数可能传入了 true, 继续向下阅读,我们找到了具体的实例化的代码,这证实了我们的猜想

所以最简单的解决方案就清晰了,我们需要修改的正是这个 isUltrawarm 方法。使其一律返回 false, 这样就允许了所有维度水的放置.

# 2. 动手实践

相信对 Mixin 有简单了解的读者已经动手写出自己的代码了,这里笔者仅简单提几点注意事项,以供对 Mixin 了解不深的读者参考,相信你们会在之后的学习中逐渐理解 Mixin 的实质

  • 所有 Mixin 的相关 "类" 必须放在一个独立的包下,并且在对应 json 中明确声明

  • Mixin 类需要使用 @Mixin 来修饰,参数提供想要注入的类

  • 被 @Mixin 修饰的类,在实质的角度上来说不是类,而是指导代码注入过程的一个说明

  • @Overwrite 表示用下列代码完全替换目标方法的内容

这里笔者放出自己的代码,并简要说明一下定位与修改的过程.

@Mixin(DimensionType.class) // 只有被 @Mixin 修饰的类才会被识别并注入
public class DimensionTypeMixin {
    /**
     * @author TuRou
     * Make water can place at all dimensions.
     */
    @Overwrite() // 将目标方法完全替换为下方的代码
    public boolean isUltrawarm() {
        return false; // 所有维度都返回 false, 包括地狱
    }
}

只需要短短的五行代码,我们便达成了我们的目的,简单地修改一个 Minecraft 的内部方法。之后将 Mixin 类添加到对应 json 文件中就可以了

package 指向所有 Mixin 文件的存放包,mixins 是存放所有 Mixin 类的列表。最后不要忘记在主 json 中加入 mixins.json 的引用,之后我们就可以进游戏测试了。在之后部分的文章中,将不会再提及这部分内容,如果读者发现自己的 Mixin 没有被正确加载,不妨检查一下有没有将其添加到 mixins 中.


# 二、方块与魔法的世界 - 吟唱施法

本部分将以聊天栏吟唱施法为实例,使读者对定位注入的位置,以及源代码的感知进入一个更深层次的境界,同时启发读者产生更多的想法和点子。我们将从聊天栏消息的注入出发,深入浅出揭秘 Minecraft 聊天的处理方式,这里也包含一小部分 Fabric 网络通信的方式,读者可以选择性阅读.

# 1. 明确需求

作为一名出色的魔法术士,我们应有流畅的魔法吟唱的能力,在 Minecraft 世界中,我们可以用聊天的形式呈现,在聊天栏中说出对应魔咒,我们就可以触发相应的魔法,产生一些特殊的效果。我们希望自己的魔咒不在公屏上显示,甚至我们输入魔咒的时候会发出悦耳的声音,并且也不允许玩家使用方向上键来直接输入历史魔法... 这样,我们可以做出一个趋于完善的吟唱施法系统。这里我们重新梳理一下需求

  1. 拦截聊天栏魔咒消息的发送,判断施法是否成立

  2. 在输入魔咒时发出悦耳的声音

  3. 禁止魔咒消息储存至消息历史

如此这样,我们把目光主要定位在了聊天的相关实现上,我们想要寻找的类,大概率包含 Chat, Message 字样。之后,我们就可以开始着手分析 Minecraft 关于聊天相关实现的源码了.

# 2. 渐入佳境

搜索后我们发现,我们找到了三个可疑目标,分别是 ChatHud, ChatMessages, ChatScreen

经过简单的分析我们发现:

  • ChatMessages 主要负责聊天信息中文字的渲染,这对我们的工作没有帮助

  • ChatScreen 中绘制了我们聊天栏的 GUI, 聊天信息的发送可能在这里被处理

  • ChatHud 着重于聊天栏的文本部分,我们聊天信息的历史可能保存在这里

为了印证我们的猜测与分析,我们尝试依靠我们的直觉来寻找我们需要的代码。让我们回想在游戏中我们发送聊天信息的操作流程。我们首先按 T 打开聊天栏,在文本框中输入内容,最后单击回车发送消息.

细心的读者可能已经发现,这个按 T 打开的聊天栏正是 ChatScreen 所描述的界面,而文本框就是 ChatScreen 类中发现的一个名为 chatField 的属性。而点击回车这个动作,在 keyPressed 方法中有所体现.

String string = this.chatField.getText().trim();
if (!string.isEmpty()) { // 如果不为空
    this.sendMessage(string);
}
this.client.openScreen((Screen)null); // 关闭界面
return true;
位于: net.minecraft.client.gui.screen.ChatScreen

不难猜想,我们需要深入探究 sendMessage 是以怎样的方式发送玩家的聊天信息的,我们查询相关实现,发现了如下的代码.

public void sendMessage(String message, boolean toHud) {
    if (toHud) {
        this.client.inGameHud.getChatHud().addToMessageHistory(message);
    }
    this.client.player.sendChatMessage(message);
}
位于: net.minecraft.client.gui.screen.Screen

这里我们同时有了两个发现,发送消息之前,会调用 ChatHud 类中的 addToMessageHistory 方法来将该聊天添加到聊天历史中,发送消息时调用 ClientPlayerEntity 的 sendChatMessage 方法来以客户端玩家的身份将消息发送至 server-side. 这里不难猜想是发送了一个 C2S 的网络包

public void sendChatMessage(String message) {
    this.networkHandler.sendPacket(new ChatMessageC2SPacket(message));
}
位于: net.minecraft.client.network.ClientPlayerEntity

接下来我们的工作便清晰了,注入这两个方法并按照我们的需求进行修改

# 3. 唯手熟尔

在我们的 mixin 包中新建 ClientPlayerEntityMixin 类,写入如下内容

@Mixin(ClientPlayerEntity.class)
public class ClientPlayerEntityMixin {
    @Inject(method = "sendChatMessage", // 要注入的方法 sendChatMessage
            at = @At("HEAD"), // 表明插入的位置在方法的头部
            cancellable = true // 表明我们可以中途取消 (return) 这个方法
    )
    private void sendChatMessage(String message, CallbackInfo ci) {
        if (message.toLowerCase().startsWith("maho shoot")) {
            ClientPlayNetworking.send(ChantConstant.CHANT_IDENTIFIER, PacketByteBufs.create().writeString(message));
            // 将施法网络包发送,这里会在下面部分进行讲解
            ci.cancel();
        }
    }
}

这个类被 @Mixin 修饰,意味着它是一个用来补充 ClientPlayerEntity 内容的 "类", 与上文不同的是,这里我们对想要修改的方法添加了 @Inject 注解,而不是 @Overwrite 注解,相信读者已经通过字面意思猜到了二者之间的区别

@Inject 注解表示将下面的代码插入到目标方法内,我们在目标方法 ("sendChatMessage") 的头部 ("HEAD") 插入一个可取消的 (cancellable = true) 的方法.

方法体内部,我们判断 message 内容是否以 "maho shoot" 开头,如果是的话,就把施法的动作发送给 server-side (这里涉及到一个 C2S 的网络包,会在下文讲解), 以便于我们在逻辑服务端部分处理施法的具体效果。最后,我们调用 ci.cancel (); 方法,代表我们希望在这里调用 return () 结束这个方法,不再继续向下执行,因为我们不想让施法的聊天消息再发出了.

所以在游戏运行时,Minecraft 会把我们的 sendChatMessage 方法当成是这样的:

public void sendChatMessage(String message) {
    if (message.toLowerCase().startsWith("maho shoot")) {
        ClientPlayNetworking.send(ChantConstant.CHANT_IDENTIFIER, PacketByteBufs.create().writeString(message));
        return();
    }
    this.networkHandler.sendPacket(new ChatMessageC2SPacket(message));
}

这符合我们的预期效果。接下来我们要做的事情就是在 server-side 处理我们这个网络包,这已经超出我们本节教程的范围,会在下文简略介绍。接下来我们来处理 messageHistory.

在我们的 mixin 包中新建 ChatHudMixin 类,写入如下内容

@Mixin(ChatHud.class)
public class ChatHudMixin {
    @Inject(method = "addToMessageHistory",
            at = @At("HEAD"),
            cancellable = true
    )
    public void addToMessageHistory(String message, CallbackInfo ci) {
        if (message.toLowerCase().startsWith("maho shoot")) {
            ci.cancel();
        }
    }
}

相信这里笔者不用加以解释,读者已经充分理解并明白了这段代码的含义和作用.

最后,我们来完成第二个需求,在输入魔咒时发出悦耳的声音。先前认真阅读过 ChatScreen 类的读者可能会发现这样一个方法

private void onChatFieldUpdate(String chatText) {
    String string = this.chatField.getText();
    this.commandSuggestor.setWindowActive(!string.equals(this.originalChatText));
    this.commandSuggestor.refresh();
}
位于: net.minecraft.client.gui.screen.ChatScreen

看起来这个类原本是为了在输入指令的时候进行自动补全的,这里刚好迎合了我们的需求,有了之前的经验,现在我们很清晰地明白,我们将使用 Inject 注解在这个方法中填上有关播放音效的代码.

在我们的 mixin 包中新建 ChatScreenMixin 类,写入如下内容

@Inject(method = "onChatFieldUpdate",
        at = @At("RETURN") // 这里我们尝试在方法结束前插入这段代码
)
public void onChatFieldUpdate(String message, CallbackInfo ci) {
    ClientPlayerEntity player = MinecraftClient.getInstance().player;
    if (message.toLowerCase().startsWith("maho shoot ") && player != null) {
        player.playSound(SoundEvents.ENTITY_EXPERIENCE_ORB_PICKUP,0.1f, 0.8f);
    }
}

方法体内部,我们用 MinecraftClient.getInstance ().player 获取到的玩家对象是运行在逻辑客户端上的玩家,因为我们仅仅是想播放声音,这种操作是可取的.

之后,我们判断聊天信息是否是以施法的前缀 "maho shoot" 开头的,如果是这样的话,就播放经验球捡起的声音。需要注意的是,这个声音因为是在客户端玩家对象上播放的,固然只有施法的玩家自己能听见,若想要让服务器中附近的所有玩家听到,也要用我们上文类似的方法发送一个 C2S 网络包,并让播放音效的语句在逻辑服务端执行.

最后,将我们新建的两个 Mixin 类添加到 mixins.json 中,我们就可以打开游戏测试了.

# 4. 请问您今天要来点额外知识吗?

# Client/ServerPlayNetworking

上文我们调用了 ClientPlayNetworking.send(ChantConstant.CHANT_IDENTIFIER, PacketByteBufs.create().writeString(message)); 方法,将施法的网络包发给了 server-side. 这里我们提供了两个参数,一个是我们要发送的字节集数据,另一个是网络包的标识符。这里我们的标识符在一个名为 ChantConstant 的类中定义 public static final Identifier CHANT_IDENTIFIER = new Identifier("shinto", "chant");

接下来我们来到服务端侧,来看看处理施法的过程

新建 network 包,创建 ServerPacketHandler 类,写入以下内容

public class ServerPacketHandler {
    public static void init() {
        ServerPlayNetworking.registerGlobalReceiver(ChantConstant.CHANT_IDENTIFIER, (server, player, handler, buf, responseSender) -> {
            String str = buf.readString(32767);
            server.execute(() -> {
                new StatementResolver().resolve(str, player);
            });
        });
    }
}

这里我们调用 ServerPlayNetworking 类来注册一个监听器,等到网络包一到达,我们读取其中的字符串,并且用服务端线程执行施法的相关代码,这里笔者施法的解析放在了 StatementResolver 类中,对笔者魔法 Mod 感兴趣的读者可以前来阅读

# @Inject 注解的 locals 参数

当我们注入一个方法时,可能会有需要访问方法内的局部变量的时候,虽然本教程尚未涉及,这里笔者也希望给予补充。这种情况下,我们通常会给 @Inject 注解添加 locals 参数,这样我们就可以捕获局部变量表了.

比如说我们可以让 @Inject 注解变成这个样子

@Inject(method = "myFunction",
 at = @At("HEAD"), 
cancellable = true, 
locals = LocalCapture.CAPTURE_FAILHARD // 我们需要捕捉这个方法的局部变量表
 //FAILHARD 表示一旦捕捉失败,游戏不会继续加载,发生崩溃
 //FAILSOFT 表示一旦捕捉失败,游戏继续加载,放弃注入这个方法
)

那么我们为什么要用这种复杂的方式呢?简要来说,局部变量表具有不确定性,Mixin 只能根据字节码推测局部变量,这导致当其他 Mod 一旦与你有共同的需求,都注入了同一个方法并且给这个方法添加了局部变量,事情可能就会复杂起来,因为此时局部变量表对于两个 Mod 双方在游戏实际跑起来之前都是不确定的。所以双方都需要做好一旦方法注入失败之后的打算.

所以,我们的局部变量列表并不是所见即所得的,而且我们也不能通过变量的确切名字来访问变量 (因为 Mixin 也不知道这个名字究竟指哪个变量,Mixin 只有一个标记着数据类型的有序列表), 所以推荐读者第一次注入该方法时将 locals 设为 LocalCapture.PRINT ,以便确定具体的局部变量表,如果知道可能产生冲突的 Mod, 可以将那个 Mod 加入进开发环境,再重新 PRINT 并编写重载后的代码,将可能冲突的情况避免。mixin 会告诉你此时的方法应该怎么写,读者可以加以尝试

这里笔者推荐读者阅读 CoreModTutor 中的相关内容,结合 Minecraft 原版中的具体例子进行理解,这里笔者就不过多赘述了.

注:这里笔者特别感谢 xfl03 的 CoreModTutor 中对 Mixin 系统而详细的介绍,笔者的知识储备部分来源于此,当读者希望对核心概念有更深理解时,可以阅读 xfl03 的 [CoreModTutor](5 Mixin - CoreModTutor (gitbook.io))


# 三、关于工作台上现在可以保持物品不掉下来这档事

本部分将为读者展现一个非常常规的需求:将物品保持在工作台中不掉落。在探究过程中,我们将继续沿用我们上面现象到本质的探究思路,从工作台本身出发,一步步探求其背后的代码实现以及注入方案,培养读者探究一个未知问题的能力.

# 1. 切入要点

同样的,我们利用 idea 的搜索功能搜索 Crafting 关键字,并且筛选我们可能需要用到的类,我们最后留下了下面这些看起来与我们需求有关的类

  • CraftingTablework Block 类的子类,定义了工作台这个方块

  • CraftingScreen Screen 类的子类,定义了合成界面是如何渲染的

  • CraftingScreenHandler 处理合成界面的类,可能与创建合成 GUI 有关

  • CraftingInventory 处理合成界面九宫格上的物品的类

经过对代码简单的阅读,我们先排除掉了 CraftingScreen 类,因为 CraftingScreen 类中大多是关于界面渲染有关的方法和语句,这显然与我们的需求无关.

之后为了理清各个类之间的关系,我们开始按照我们的思路分析问题。设想一下,我们在游戏中使用工作台的第一个动作,就是对工作台右键单击,我们可以以此为切入点,看看右击工作台会发生什么。在 CraftingTableBlock 类的 onUse 方法中,调用了 createScreenHandlerFactory 来打开合成的 GUI 界面.

public NamedScreenHandlerFactory createScreenHandlerFactory(BlockState state, World world, BlockPos pos) {
    return new SimpleNamedScreenHandlerFactory((i, playerInventory, playerEntity) -> {
        return new CraftingScreenHandler(i, playerInventory, ScreenHandlerContext.create(world, pos));
    }, TITLE);
    // 在玩家的 GUI 中显示新创建的 CraftingScreenHandler 界面
}
位于: net.minecraft.block.CraftingTableBlock

可以看到,这里右键单击时,是实例化了一个 CraftingScreenHandler 类,在这个类的构造方法中,我们可以找到下面这样的语句

public CraftingScreenHandler(int syncId, PlayerInventory playerInventory, ScreenHandlerContext context) {
    super(ScreenHandlerType.CRAFTING, syncId);
    this.input = new CraftingInventory(this, 3, 3);
    // 在构造方法中创建一个 3*3 的 CraftingInventory, 用来存放待合成品
    ......
}
位于: net.minecraft.screen.CraftingScreenHandler

当我们继续向下查询 CraftingInventory 类,我们发现了下面这样的代码

private final DefaultedList<ItemStack> stacks;
public CraftingInventory(ScreenHandler handler, int width, int height) {
    this.stacks = DefaultedList.ofSize(width * height, ItemStack.EMPTY);
    //CraftingInventory 中维护一个有序列表,来存放一些 Itemstack
    ......
}
位于: net.minecraft.inventory.CraftingInventory

相信聪明的读者已经从这些代码中明确了三个类之间的关系.

我们期望 CraftingScreenHandler 类中能有一个类似于 setInputItems () 和 getInputItems () 的方法,如果这样的话,我们只需要在关闭界面的时候把 get 到的物品列表储存在方块内,并且在打开界面的时候把物品列表 set 进去就可以了,我们清楚的知道,mixin 应该能帮助我们做到这一点.

我们的思路已经基本明确,现在的首要任务便是利用 mixin 为我们添加进去这一组 getset

# 2. 做出尝试

在我们的 mixin 包中创建 CraftingScreenHandlerMixin 类,按照我们的直觉,我们的代码可能是这样的

@Mixin(CraftingScreenHandler.class)
public class CraftingScreenHandlerMixin {
    private DefaultedList<ItemStack> getInputItems() {
        return input.stacks;
    }
    private void setInputItems(DefaultedList<ItemStack> itemStacks) {
        if (itemStacks != null && !itemStacks.isEmpty()) {
            for (int i = 0; i < itemStacks.size(); i++) {
                input.stacks.set(i, itemStacks.get(i));
            }
            onContentChanged(null); // 为了让合成产物更新,改变完物品列表后应该手动进行刷新
        }
    }
}

idea 此时为我们报出了许多错误,其中最为明显的就是找不到 input 这个变量,我们清楚地记得,input 这个成员是在 CraftingScreenHandler 中明确定义了的,为什么这里却找不到了呢...

了解 Java 的读者此时可能已经意识到了事情的不对,虽然我们目标类中有 input 这个成员,但是对于 Java 编译器,CraftingScreenHandlerMixin 中确确实实没有声明 input, 这里 mixin 为我们提供了一种方法,@Shadow 注解

@Shadow 注解意味着被标记的成员或方法在目标类中出现。当我们需要引用这个成员或方法的时候,我们可以用 @Shadow 注解来欺骗 Java 编译器,所以我们在代码中添加下面几行

@Shadow @Final private CraftingInventory input;
// 这里还添加了 @Final 注解,这个很好理解,目标类中原本被 final 修饰的成员推荐在 mixin 类中添加 @Final 注解,这样当你不小心更改了这个成员的值的时候 Mixin 会提醒你的
@Shadow public abstract void onContentChanged(Inventory inventory);
// 这里 @Shadow 修饰的方法 onContentChanged 被定义为了抽象方法,这也符合实际情况,因为 onContentChanged 的具体实现是存放在 CraftingScreenHandler 类中的
// 这里我们的 Mixin 类可能需要随之更改为抽象类,不过这没什么影响

然后,我们发现代码中仍旧有红色的报错,它告诉我们 stacks 是 CraftingInventory 的私有属性,我们无法访问。强大的 mixin 当然能简单地破除访问限制,这里我们需要新建一个接口,来读取 stacks.

在 mixin 包下创建一个类 IMixinCraftingInventory, 写入如下代码

@Mixin(CraftingInventory.class)
public interface IMixinCraftingInventory {
    @Accessor
    DefaultedList<ItemStack> getStacks();
}

@Accessor 注解会帮助我们获取我们想要的字段,而直接忽略访问限制。需要注意的是方法名需要以 get 开头,并且返回值就是想要获取的字段的类型,我们可以使用如下的方式来访问这个字段

((IMixinCraftingInventory) input).getStacks()

所以最终,我们的 CraftingScreenHandlerMixin 类应该变成这样子

@Mixin(CraftingScreenHandler.class)
public abstract class CraftingScreenHandlerMixin {
    @Shadow @Final private CraftingInventory input;
    @Shadow public abstract void onContentChanged(Inventory inventory);
    private final DefaultedList<ItemStack> itemList = ((IMixinCraftingInventory) input).getStacks();
    private DefaultedList<ItemStack> getInputItems() {
        return itemList;
    }
    private void setInputItems(DefaultedList<ItemStack> itemStacks) {
        if (itemStacks != null && !itemStacks.isEmpty()) {
            for (int i = 0; i < itemStacks.size(); i++) {
                itemList.set(i, itemStacks.get(i));
            }
            onContentChanged(null);
        }
    }
}

至此,我们彻底完成了我们需要的这两个方法

# 3. 最后一步

之后,我们就需要适时地保存或者读取我们的物品列表了。相信聪明的读者现在立马脑中会想到一件事情,我们起初查询 crafting 关键字的时候,没有发现工作台有储存数据的能力,即我们没有找到类似 CraftingTableBlockEntity 的字眼,这意味着我们需要自己创建一个 BlockEntity 来储存需要的数据.

新建 blockentity 包,创建 CraftingTableBlockEntity 类并写入以下内容

public class CraftingTableBlockEntity extends BlockEntity {
    public DefaultedList<ItemStack> innerItemStacks = DefaultedList.ofSize(9, ItemStack.EMPTY);
    public CraftingTableBlockEntity() {
        super(MyMod.CRAFTINGTABLE_BLOCKENTITY);
    }
    public CompoundTag toTag(CompoundTag tag) {
        super.toTag(tag);
        Inventories.toTag(tag, innerItemStacks);
        return tag;
    }
    @Override
    public void fromTag(BlockState state, CompoundTag tag) {
        super.fromTag(state, tag);
        if (innerItemStacks != null && !innerItemStacks.isEmpty()) {
            Inventories.fromTag(tag, innerItemStacks);
        }
    }
}

我们更改 Mod 主类,注册 BlockEntity

public class MyMod implements ModInitializer {
    public static BlockEntityType<CraftingTableBlockEntity> CRAFTINGTABLE_BLOCKENTITY;
    @Override
    public void onInitialize() {
        CRAFTINGTABLE_BLOCKENTITY = Registry.register(Registry.BLOCK_ENTITY_TYPE, "mymod:crafting_table", BlockEntityType.Builder.create(CraftingTableBlockEntity::new, Blocks.CRAFTING_TABLE).build(null));
    }
}

到这里,我们的工作台已经有了存取物品的能力,下面我们添加相关代码。如果仔细阅读过 CraftingScreenHandler 类的读者可能会注意到一个名为 close 的方法

public void close(PlayerEntity player) {
    super.close(player);
    this.context.run((world, blockPos) -> {
        this.dropInventory(player, world, this.input);
    });
}
位于: net.minecraft.screen.CraftingScreenHandler

很明显,原版的逻辑是关闭界面后掉落所有物品。这里我们将更改 close 方法,使关闭界面时工作台内物品得以保存。我们在 CraftingScreenHandlerMixin 中添加下面的代码

@Shadow @Final private ScreenHandlerContext context;
/**
 * @author TuRou
 * Filled the BlockEntity with the data in the crafting grid.
 */
@Overwrite()
public void close(PlayerEntity player) {
    context.run((world, pos) -> {
        BlockEntity blockEntity = world.getBlockEntity(pos);
        if (blockEntity != null) {
            ((CraftingTableBlockEntity) blockEntity).innerItemStacks = getInputItems();
        }
    });
}

之后我们需要让合成 GUI 被打开的时候从 BlockEntity 中读取并填充列表,依据我们之前的分析,GUI 打开的语句在在 CraftingTable 类中,我们在 mixin 包中新建类 CraftingTableMixin, 并先写入以下内容

@Mixin(CraftingTableBlock.class)
public class CraftingTableMixin extends Block implements BlockEntityProvider {
    @Nullable
    @Override
    public BlockEntity createBlockEntity(BlockView world) {
        return new CraftingTableBlockEntity();
    }
    @Override
    public void afterBreak(World world, PlayerEntity player, BlockPos pos, BlockState state, @Nullable BlockEntity blockEntity, ItemStack stack) {
        super.afterBreak(world, player, pos, state, blockEntity, stack);
        if (blockEntity != null) {
            for (ItemStack innerItemStack : ((CraftingTableBlockEntity) blockEntity).innerItemStacks) {
                world.spawnEntity(new ItemEntity(world, pos.getX(), pos.getY(), pos.getZ(), innerItemStack));
            }
        }
    }
}

我们给合成台添加 BlockEntity, 并要求在方块被破坏的时候掉出所有的物品。之后我们来重写 createScreenHandlerFactory 方法,在 CraftingTableMixin 中添加以下内容

/**
 * @author TuRou
 * Handle Crafting Progress.
 */
@Overwrite
    public NamedScreenHandlerFactory createScreenHandlerFactory(BlockState state, World world, BlockPos pos) {
        BlockEntity blockEntity = world.getBlockEntity(pos);
        if (blockEntity == null) {
            blockEntity = new CraftingTableBlockEntity();
            world.setBlockEntity(pos, blockEntity);
        }
        DefaultedList<ItemStack> itemStacks = ((CraftingTableBlockEntity) blockEntity).innerItemStacks;
        return new SimpleNamedScreenHandlerFactory((i, playerInventory, playerEntity) -> {
            CraftingScreenHandler screenHandler = new CraftingScreenHandler(i, playerInventory, ScreenHandlerContext.create(world, pos));
            if (!itemStacks.isEmpty()) screenHandler.setInputItems(itemStacks);
            return screenHandler;
        }, new TranslatableText("container.crafting"));
    }

这时 idea 给了我们红色报错,提示我们 setInputItems 方法不存在,这里相信读者已经清楚发生了什么事情,读者可能尝试解决问题,给 screenHandler 添加了类型强转: (CraftingScreenHandlerMixin) (Object) screenHandler 但当此时读者启动游戏,游戏却给我们抛了一个 ClassNotFoundException

这里的正确做法是使用一个接口来过度,在在非 mixin 包中新建类 ICraftingScreenHandler, 并且写入以下内容

public interface ICraftingScreenHandler {
    void setInputItems(DefaultedList<ItemStack> itemStacks);
}

之后我们用 ((ICraftingScreenHandler) screenHandler).setInputItems() 的方式,就可以顺利调用 setInputItems 方法了

最后,将我们涉及到的所有类添加到 mixins.json 中,启动游戏,相信读者已经测试到了自己想要的效果.

注:我们上文强调,ICraftingScreenHandler 必须放在非 mixin 包中,很明显地,这个类是为了欺骗 Java 虚拟机,使其相信 screenHandler 一定实现了 ICraftingScreenHandler 接口,而事实上,这个接口没有被目标类使用 implements 显式实现,但接口里面的方法 (setInputItems) 是的的确确存在的 (我们利用 Mixin 加进去的), 我们敢于使用这种方式来蒙骗 Java 虚拟机从而调用到目标方法。所以显然,ICraftingScreenHandler 并不属于我们整个 Mixin 流程中的一部分,它是没有必要在游戏加载之前提前被注入的.
读者这时可能会想到我们上文声明的 IMixinCraftingInventory, 这个接口是实打实需要注入然后被目标类实现的,可是回忆一下,我们并没有在目标类中实现这个接口中的方法...... 其实是 Mixin 帮我们根据接口的内容自动生成的 (因为我们的需求很固定,就是访问目标类的成员变量), 固然,这个接口是需要在游戏加载前被注入的,所以它应放在 mixin 包内


# 总结

如果读者跟随笔者的思路,对这三个实例进行了由浅入深的分析,相信读者已经对 mixin 有了新的认识和理解,并且有了自己解决问题的能力。相信读者已经体会到了 Minecraft 源码注入的精巧之处和强大之处,希望各位开发者能活用 mixin 技术,创造出属于自己的技术.

本文的创作大力感谢 xfl03 的 [CoreModTutor](5 Mixin - CoreModTutor (gitbook.io)).

感谢 dengyu洞穴夜莺给笔者提出改进意见

本文部分源代码和思路摘自笔者曾经开发的 Mod: [Shinto](TROU2004/Shinto (github.com)), [Benchworks](TROU2004/Benchworks (github.com))