如何改MC代码?最全最易懂的Mixin+AccessWidener教程,学完变大佬!


快速入门视频教学

Mixin:修改代码的工具

“Mix in”意为“混入”。顾名思义,该API用于修改游戏代码——以在指定位置混入指定的代码来实现。

其目的是为修改游戏代码制定统一的标准(代替直接覆盖原代码的方式),有助于跨版本和模组之间实现兼容

本文以Fabric为例进行Mixin教学,(Neo)Forge通用。

前置知识:Java基础语法、类和面向对象、泛型、注解等。

注解知识回顾(了解)

要被混入的代码需要被Mixin库特有的注解标注。

先回顾一下注解的基础知识(急于求成可以先不看,把它当成一个特殊语法就行):

  • 注解功能建立在反射机制的基础上。
  • 定义注解类型用@interface,其本质就是一个继承自java.lang.annotation.Annotation接口,内部的定义其实就是一个带默认值的方法。
  • 注解本身并不影响程序的逻辑和运行,它只是一种元数据,用于提供额外信息和指示,以便在编译时、运行时或其他工具处理代码时使用(@RetentionPolicy)。但它们在运行时被用来实现各种功能,从而间接地影响程序的运行。
  • 注解可用于包、类、构造方法、成员变量、方法、参数等声明中(@Target)。
  • 注解的三大类别:标准注解、元注解、自定义注解。
  • 注解一般不需要自己写,而是用别人写好的。一些常用注解:@Override@NotNull/@Nullable@Contract@FunctionalInterface@SupressWarning@Deprecated

Mixin配置

  1. 使用IntelliJ IDEA(还在用Eclipse?),安装插件Minecraft Development,以辅助Mixin代码写作。

    插件常用功能(学完再来看这块):

    1. 快捷添加Mixin类到JSON文件


    PS:手动添加需要掌握服务端和客户端的概念,注意客户端的Mixin类(如Fabric中被@Environment(value=EnvType.CLIENT)注解的类)不能放到别的地方

    {
      "required": true,
      "minVersion": "0.8",
      "package": "net.fabricmc.example.mixin", // Mixin类存放的文件夹(包)
      "compatibilityLevel": "JAVA_17",
      "mixins": [], // 两个端都要的Mixin类
      "client": [ // 客户端的Mixin类
        "TitleScreenMixin"
      ],
      "server": [], // 服务端的Mixin类
      "injectors": {
        "defaultRequire": 1
      }
    }
    
    1. 自动生成字段描述符(field descriptor)
    2. 自动补全方法参数
  2. 看官方文档完成配置。

    • Fabric看这篇(其实Forge也能用,但可能要考虑额外配置Gradle,参考下一行给出的博客)
    • Forge官方文档没有给出,可以参考这篇博客

Mixin注解

注解含义
@Mixin说明该类是存放要混入的代码的类(下文称为“Mixin类”)混入代码的前提
@Shadow影射原来的类的成员(变量常量和方法),用于引用原类的变量或方法无处不在,可以标注变量和方法
@Unique表示被标注的成员只会向原类添加,而不会覆盖如果Mixin类内的成员未加任何注解,则会覆盖原类的成员。而加上@Unique则不会。
@Inject向代码注入内容最常用的修改注解
@Redirect重定向(替换)方法调用不能有两个模组重定向一个对象,实在没办法时再用
@ModifyArg修改调用某方法的参数注意index是从0开始数的

更多详细内容请参考Mixin案例(推荐)和Mixin官方文档(不推荐)。

@Mixin:混入代码的前提

@Mixin注解用于标注某个类——其中储存的是要混入的代码。

推荐写法:@Mixin(ClassName.class),其中ClassName是要被混入代码的原来的类(下文称为“原类”)。

看个例子就懂了:

如果我们想修改玩家实体类的代码,则需要先这样做:

import net.minecraft.entity.player.PlayerEntity;

@Mixin(PlayerEntity.class)
public abstract class PlayerEntityMixin extends LivingEntity {
    // 其他Mixin代码,后面会讲
}

解析:

  • @Mixin(PlayerEntity.class)注解的括号内填的是玩家实体的类——这是我们需要混入代码的类。
  • @Mixin(PlayerEntity.class)标注的类包含的是我们要混入的代码。
  • extends表示继承的是PlayerEntity的父类(即保持Mixin类与原类extendsimplements一致)——其目的是为了能够调用父类的成员(这样只是为了通过编译器,没有别的用途)。
  • abstract表示抽象类,这是为了简化代码(少一些为了通过编译器的无意义代码),并无实际意义。

注意,这么做并不会有任何效果,只是起一个打基础的作用。好比要烤肉(修改代码),你需要火(后面的代码)。但@Mixin只起一个充当燃料和引火物的作用。

此外,还有第二种写法(不推荐):

@Mixin(targets = "net.minecraft.rest_package_name")

@Shadow:引用原类的成员

前面说了继承能调用原类的父类成员,但如果是直接调用原类的成员,又该怎么办?

@Shadow

引用原类的变量和常量

以玩家实体类为例,如果我们想获取玩家的经验等级,就需要访问其中的experienceLevel变量。在反编译代码中是这样的:

public abstract class PlayerEntity extends LivingEntity {
    // ...
    public int experienceLevel;
    // ...
}

如果要在Mixin类中访问,需要这样写:

@Mixin(PlayerEntity.class)
public abstract class PlayerEntityMixin extends LivingEntity {
    @Shadow public int experienceLevel; //访问修饰符从反编译代码中获取
    // ...
}

注意:不需要提供初始值即可直接引用。

因为被@Shadow标注的成员充当的只是占位符(Place Holder)的作用,仅仅是为了让该引用通过编译器(如果没有的话,会提示找不到该成员),其实质上并无任何作用,不会覆盖原类的对应成员,也不会引发其他任何操作。

调用原类的方法

还是以玩家实体类为例,如果我们想要给玩家造成伤害,则需要调用里面的damage方法。

在反编译代码中是这样的:

public abstract class PlayerEntity extends LivingEntity {
    // ...
	@Override
    public boolean damage(DamageSource source, float amount) {
        if (this.isInvulnerableTo(source)) {
            return false;
        }
        // 其余执行伤害处理的逻辑
    }
    // ...
}

要想调用该damage方法,需要在Mixin类中这样写:

@Mixin(PlayerEntity.class)
public abstract class PlayerEntityMixin extends LivingEntity {
    @Shadow
    public abstract boolean damage(DamageSource source, float amount);
    // ...
}

这样,我们如果想要调用该方法,就可以在该Mixin类内直接这样写(this.可省略)

this.damage(this.getWorld().getDamageSources().cactus(), 1.0F);

注意:当且仅当满足以下两种情况时,才有必要用@Shadow

  1. 调用的是原类的成员,而非原类的父类成员

当然,如果你要调用父类的成员,则不需要用@Shadow,否则如果你要调用的父类成员并未在作为子类的原类中覆盖(Override)——即多态,会因为定位失败(只能找原类的成员,而不能顺着继承链往上找)而报错。

如果父类存在私有成员,可以用后面的AccessWidener。

  1. 自己调用自己类的成员,而非别处

以上面的调用damage方法为例,如果我们在玩家类的外面调用,则不需要。

例如,让牛(在CowEntity类中调用)反抗的部分代码为:

if(this.getAttacker() instanceof PlayerEntity player /*①*/) {
	player.damage(...);
}

①:语法糖,相当于:

if(this.getAttacker() instanceof PlayerEntity) {
	PlayerEntity player = this.getAttacker();
	player.damage(...);
}

值得一提的是,插件Minecraft Development可以帮助我们自动生成@shadow,您只需要在输入的候选栏内选中该成员即可。

@Unique:只是加进去,不会覆盖

先看看@Unique的文档注释:

This annotation, when applied to a member method or field in a mixin, indicates that the member should never overwrite a matching member in the target class. This indicates that the member differs from the normal “overlay-like” behaviour of mixins in general, and should only ever be added to the target. For public fields, the annotation has no effect.

这说明@Unique有如下的特性:

  • @Unique用于标注自己写的成员,只会单纯添加进去,而不会覆盖原类的成员。
  • 对于被public修饰的成员,被@Unique标注无效。

如果自己在Mixin中新加了一个成员,而原类中刚好有重名的,则会默认将其覆盖掉。不过这种写法不太规范,请参考后面的@Overwrite

@Unique有个很有用的特性,那就是被@Unique标注的变量会在每次实例化后创建一个新的成员变量。借助这个特性,我们可以添加实体的状态、世界的属性,等等。

例如,我们想要给玩家添加一个口渴值,就可以这么写:

@Mixin(PlayerEntity.class)
public abstract class PlayerEntityMixin extends LivingEntity {
    @Unique
    private float thirst = 100.0F;
    // ...
}

这样写即使是在多人游戏中,也不会发生多个玩家共用一个口渴值的情况(如果被static修饰就会共用了~_~),因为每个玩家类实例的内部都有一个相应的储存口渴数据的浮点值。

当然,直接操作该数值会破坏封装性,可以参考游戏代码的HungerManager类进行封装操作,写成下面这样的形式(仅供参考,实际情况复杂得多):

public class ThirstManager {
    private float thirst = 100.0F;
    public float getThrst() { return this.thirst; }
    public void setThirst(float thirst) { this.thirst = thirst; }
}

后面在到PlayerEntityMixin内这样写:

@Unique
protected ThirstManager thirstManager = new ThirstManager();

如果对定义Manager这方面的内容想深入学习,可以移步至我的repo,查看PlayerEntityMixin

@Overwrite:替换原来的成员(别用)

如果想要覆盖原类中的成员,只需要将其用@Overwrite标记即可。

但这样容易引发冲突,也如果实在要重写,请参考@Inject

@Inject:向指定方法内插入点什么(重难点)

@Inject用于在指定方法内插入你自己的代码片段。(对于单词inject在此处的释义,我们取第三条:to introduce as an element or factor in or into some situation or subject——即在某个整体中引入新的个体。)

注意:

  • 它只能用于修饰方法(该注解指出@Target({ ElementType.METHOD }))。
  • 它只会增加代码,并不会减少。但是它也能删除原代码中的逻辑,即调用内置方法ci.cancel(),相当于往原来的方法中写入了return语句,从而在某些情况下阻止后续逻辑的运行。
  • 任何修改代码的方式优先推荐使用该注解实现。因为其“注入”的方式具有良好的兼容性。
基础操作

假设你需要在玩家的“刻方法”(如下,每秒执行20次,用于逻辑刷新和同步)中写入自己的代码,

public abstract class PlayerEntity extends LivingEntity {
    // ...
    @Override
    public void tick() {
        // ...
    }
    // ...
}

可以这么做:

@Mixin(PlayerEntity.class)
public abstract class PlayerEntityMixin extends LivingEntity {
    @Inject(method = "tick", at = @At("HEAD"))
    public void tick(CallbackInfo ci) {
        写入的内容;
    }
}

这样修改后的游戏代码就变成这样了:

public abstract class PlayerEntity extends LivingEntity {
    // ...
    @Override
    public void tick() {
        写入的内容;
        原来的代码;
    }
    // ...
}

让我们来解释一下:

首先是这行代码:

@Inject(method = "tick", at = @At("HEAD"))
  • @Inject括号传入了两个参数。

  • 第一个参数method:参数值"tick"就指的是PlayerEntity类中的tick方法

  • 第二个参数at:参数值@At("HEAD")指的是向方法的开头注入代码。at = 后面能填很多参数值,对应的指定位置也不同。这里先介绍一下@At()系列,其他的值后面再介绍。
    官方教程里是这么说的:

    名称描述
    HEAD方法顶部
    RETURN返回语句之前
    INVOKE在方法调用
    TAIL最终的返回语句前

    也就是说,我们向@At()内填的值不同,会将代码注入到对应方法体内的不同位置。
    还是用刚才的例子,如果我们想要将方法注入到方法返回语句之前,这么写:

    @Inject(method = "tick", at = @At("RETURN"))
    

    这样写,代码会被注入到方法体内每个return语句之前。

    后面会重点介绍INVOKE的用法。

接下来谈谈这行代码:

public void tick(CallbackInfo ci) {
  • public:访问修饰符没有严格的要求,一般与原方法保持一致,或者用private也行,只要编译器能通过即可。
  • void:返回类型一律填void,而不是遵循原方法,这是人为的硬性规定。因为这个方法并不会被获取返回值(参考后面的“中断方法或修改返回值”)。
  • tick
    • 这里是为了方便直接与原方法同名。就算该方法因此被写入原类也不影响,毕竟参数不同,有方法重载,不会混淆。
    • 你可以根据自己的喜好改成别的名字,比如说tickInjectedtickMixin
  • CallbackInfo ci:方法的返回信息参数。必填,不是原方法的值,是为了满足相应功能Mixin附加的。用于修改方法什么时候返回什么值。一般这类参数在装了Minecraft Development都能够通过IDE自动补全。后面的“中断方法或修改返回值”会进一步展开。

现在又有个问题:假设PlayerEntity的子类ServerPlayerEntity并没有覆盖PlayerEntity类的tick方法(实际上覆盖了),但我们想要让混入tick方法的代码仅对ServerPlayerEntity类有效,该怎么实现?

只需要在原基础上加个instanceof就行了!

@Mixin(PlayerEntity.class)
public abstract class PlayerEntityMixin extends LivingEntity {
    @Inject(method = "tick", at = @At("HEAD"))
    public void tick(CallbackInfo ci) {
        if((Object) this instanceof ServerPlayerEntity) { // 如果产生警告,可以用@SuppressWarnings("ConstantValue")抑制
        	写入的内容;
        }
    }
}

杂谈:
修改应当考虑对整个继承链的影响。如果修改了父类的方法,就需要考虑其子类方法对其进行override,并调用super的影响。

语法上的小问题(了解)

上面说的mixin写法是官方推荐的,当然看别人的代码也可能会碰到以下写法:

@Inject(method = {"tick"}, at = {@At("HEAD")})
@Inject(method = {"tick"}, at = @At("HEAD"))
@Inject(method = "tick", at = {@At("HEAD")})

这里的花括号表示的是数组。

通常情况下,只会将代码混入到指定的唯一方法中,所以参数值只有一个,加不加花括号无所谓。

除非你要将特定的代码注入到多个位置,否则没必要加花括号,比如说这样:

@Inject(method = {"tick", "eatFood"}, at = @At("HEAD"))
构造函数和静态初始化块的特殊表示

前面的inject大法确实一直用一直爽,但直到有一天你碰到了要向构造函数或初始化块中注入代码,就发现常规的写法满足不了了。

这个时候就需要用上特殊的方法名表示:<init><clinit>

@Inject(method = "<init>", at = @At("TAIL")) // 表示混入构造函数
@Inject(method = "<clinit>", at = @At("TAIL")) // 表示混入类的静态初始化块

<init>混入构造函数举例:

class A /*extends Object*/ { // 默认继承自Object
	int num;
    public A(int num){
        super(); // 这样写是冗余的,会自动调用默认构造函数,只是方便理解
        this.num = num;
    }
}

@Mixin(A.class)
class AMixin {
    @Inject(method = "<init>", at = @At("TAIL"))
    private void injected(CallbackInfo ci) {
        代码;
    }
}

效果:

class A {
	int num;
    public A(int num) {
        super();
        this.num = num;
        代码;
    }
}

注意:在Mixin构造函数时不要使用HEAD位置,将语句混入到super之前(super只能放在构造函数第一行),否则可能导致游戏崩溃。

<clinit>混入静态初始化块举例:

class A {}

@Mixin(A.class)
@Inject(method = "<clinit>", at = @At("HEAD"))
private void AnyNameYouLike(CallbackInfo ci) {
    代码;
}

效果:

class A {
    static {
        代码;
	}
}
中断方法或修改返回值

接下来讲一下前面在修改PlayerEntity类的tick方法时提到的CallbackInfo ci的作用。

CallbackInfo是Mixin库的一个类,用于处理在原方法中自定义返回(return)的情况。换句话说,如果在注入代码中调用了这个类的特定的中断方法(.cancel()),其效果和return一致,会阻止后续方法的执行。

例如,如果要在特定情况下阻止玩家跳跃,需要往PlayerEnity类中的jump方法(public void jump();)中写入返回逻辑。可以这样写:

@Mixin(PlayerEntity.class)
public abstract class PlayerEntityMixin extends LivingEntity {
    @Inject(method = "jump", at = @At("HEAD"), cancellable = true)
    public void jump(CallbackInfo ci) {
		if (条件) ci.cancel();
    }
}

一行一行来解释:

@Inject(method = "jump", at = @At("HEAD"), cancellable = true)

这一行是向jump方法的开头中写入public void jump(CallbackInfo ci)的代码。

其中,cancellable = true表明方法是可以被中断的。注意:如果要添加中断方法的代码一定要加,否则会报错。如果不中断的话就不需要加了。

这种参数可以用于在写入代码的方法中强制返回(即中断方法)。

public void jump(CallbackInfo ci) {

这一行传入了Mixin库定义的参数ci,用于中断方法。(如果不中断也要填,一般是IDE自动补全。)

if (条件) ci.cancel();

这一行的意思是,如果满足某个条件,就中断方法。

整个下来最终修改出的游戏代码逻辑类似于:

public void jump() {
    if (条件) return; // 新加的代码片段。用于在满足某种条件时阻止方法内逻辑的调用
    原有的代码;
}

当然,CallbackInfo类不能设置方法的返回值,只能单纯地return。因此它只适用于返回void的方法。

如果要自定义方法的返回值,则需要使用CallbackInfoReturnable类。

比如说,如果我们要让玩家免疫细雪造成的冻伤(DamageTypes.FREEZE),需要修改PlayerEntity类中的public boolean isInvulnerableTo(DamageSource damageSource)方法:

@Inject(method = "isInvulnerableTo", at = @At("HEAD"), cancellable = true)
public void isInvulnerableTo(DamageSource damageSource, CallbackInfoReturnable<Boolean> cir) {
    if (damageSource.isOf(DamageTypes.FREEZE)) cir.setReturnValue(true);
}

这相当于:

public boolean isInvulnerableTo(DamageSource damageSource) { // 判断玩家是否免疫某种伤害源
	if (damageSource.isOf(DamageTypes.FREEZE)) return true;
    原有代码;
}

详细解释一下:

public void isInvulnerableTo(DamageSource damageSource, CallbackInfoReturnable<Boolean> cir) {
  • 如果原方法有参数列表,则该控制返回的参数CallbackInfoReturnable<Boolean> cir要放在最后,且必填

  • <Boolean>指明方法到底要返回什么类型的参数。
    若要理解,不妨让我们看看CallbackInfoReturnable类的声明:

    public class CallbackInfoReturnable<R> extends CallbackInfo
    
    • <R>Result的缩写(类比Java8新特性中的Function)。这说明该泛型用于规定cir.setReturnValue方法中的参数最后的返回类型是什么。
      比如说对于isInvulnerableTo方法,要返回布尔值类型,就填<Boolean>。如果我们要修改PlayerEntity类中的public int getXpToDrop()方法,让玩家不掉落任何经验,就需要填<Integer>,并调用cir.setReturnValue(0)

    • extends CallbackInfo说明CallbackInfoReturnable继承了CallbackInfo的特性。这说明你也可以调用callbackInfoReturnable.cancel(),只不过这未指定返回值,会产生未知问题。

    • 再进一步看看CallbackInfoReturnable中的setReturnValue方法:

      public void setReturnValue(R returnValue) throws CancellationException {
      	super.cancel();
          this.returnValue = returnValue;
      }
      

      这说明CallbackInfoReturnable会做两件事:

      1. 先调用父类CallbackInfocancel进行对原方法的中断(return
      2. 再设置原方法的返回值

这个时候,可能会有人问:既然有setReturnValue,那有没有getReturnValue呢?答案是肯定的。而且它们两个能配合使用,功能强大。

举例:前面让玩家免疫冻伤的代码也可以这么写:

@Inject(method = "isInvulnerableTo", at = @At("RETURN"), cancellable = true)
public void isInvulnerableTo(@NotNull DamageSource damageSource, CallbackInfoReturnable<Boolean> cir) {
    cir.setReturnValue(cir.getReturnValue() || damageSource.isOf(DamageTypes.FREEZE));
}

而反编译代码是这样的:

public boolean isInvulnerableTo(DamageSource damageSource) {
    if (super.isInvulnerableTo(damageSource)) {
        return true;
    } else if (damageSource.isIn(DamageTypeTags.IS_DROWNING)) {
        return !this.getWorld().getGameRules().getBoolean(GameRules.DROWNING_DAMAGE);
    } else if (damageSource.isIn(DamageTypeTags.IS_FALL)) {
        return !this.getWorld().getGameRules().getBoolean(GameRules.FALL_DAMAGE);
    } else if (damageSource.isIn(DamageTypeTags.IS_FIRE)) {
        return !this.getWorld().getGameRules().getBoolean(GameRules.FIRE_DAMAGE);
    } else if (damageSource.isIn(DamageTypeTags.IS_FREEZING)) {
        return !this.getWorld().getGameRules().getBoolean(GameRules.FREEZE_DAMAGE);
    } else {
        return false;
    }
}

我们这样做相当于将原来的代码改成:

public boolean isInvulnerableTo(DamageSource damageSource) {
    if (super.isInvulnerableTo(damageSource)) {
        return true || damageSource.isOf(DamageTypes.FREEZE);
    } else if (damageSource.isIn(DamageTypeTags.IS_DROWNING)) {
        return !this.getWorld().getGameRules().getBoolean(GameRules.DROWNING_DAMAGE) || damageSource.isOf(DamageTypes.FREEZE);
    } else if (damageSource.isIn(DamageTypeTags.IS_FALL)) {
        return !this.getWorld().getGameRules().getBoolean(GameRules.FALL_DAMAGE) || damageSource.isOf(DamageTypes.FREEZE);
    } else if (damageSource.isIn(DamageTypeTags.IS_FIRE)) {
        return !this.getWorld().getGameRules().getBoolean(GameRules.FIRE_DAMAGE) || damageSource.isOf(DamageTypes.FREEZE);
    } else if (damageSource.isIn(DamageTypeTags.IS_FREEZING)) {
        return !this.getWorld().getGameRules().getBoolean(GameRules.FREEZE_DAMAGE) || damageSource.isOf(DamageTypes.FREEZE);
    } else {
        return false || damageSource.isOf(DamageTypes.FREEZE);
    }
}

注意:

  • at = @At("RETURN")会将指定的同一代码片段写入所有的return语句。

  • 实际上,代码片段会被注入到return语句的上一行。如果调用了getReturnValue,会先预判该return语句的返回值,并删除原有的return语句,添加新的return语句。

    public boolean isInvulnerableTo(DamageSource damageSource) {
        if (super.isInvulnerableTo(damageSource)) {
            // 删掉这行:return true;
            // 添加这行:return true || damageSource.isOf(DamageTypes.FREEZE); 其实这行执行到true语句就直接返回true了,并不会考虑后面的东西。不过对于后面的代码就不一定了。
        }
        // 后面同理...
    }
    
  • 建议只在at = @At("RETURN")中获取方法的返回值,否则可能出现未知问题。

再以PlayerEntity类中的public int getXpToDrop()方法为例,如果我们让玩家掉落的经验变为原来的一半,可以这么写:

@Inject(method = "getXpToDrop", at = @At("RETURN"), cancellable = true)
public void getXpToDrop(CallbackInfoReturnable<Integer> cir) {
    cir.setReturnValue((int) (cir.getReturnValueI() / 2.0));
    // 当然,你也可以继续用cir.getReturnValue()。此处用cir.getReturnValueI()是强制让获取的返回值为整型。类似的用法还有cir.getReturnValueZ()【强制返回布尔值】、getReturnValueF()【强制返回浮点值】,等等。
}

这相当于:

public int getXpToDrop() {
    if (!this.getWorld().getGameRules().getBoolean(GameRules.KEEP_INVENTORY) && !this.isSpectator()) {
        int i = this.experienceLevel * 7;
        return (int)(i > 100 ? 100 : i) / 2.0;
    } else {
        return (int)(0 / 2.0);
    }
}

至此,算是讲明白了CallbackInfoCallbackInfoReturnable了。

当然,前面表示的那些修改后的代码只是等效代码,并不是实际上修改后的代码。比如说官方给出的修改原方法返回值后的代码是这样的:

  public int foo() {
    doSomething1();
    doSomething2();
-   return doSomething3() + 7; // 删除
+   int i = doSomething3() + 7; // 添加:
+   CallbackInfoReturnable<Integer> cir = new CallbackInfoReturnable<Integer>("foo", true, i);
+   injected(cir);
+   if (cir.isCancelled()) return cir.getReturnValue();
+   return i;
  }

这部分可以自己看看官方文档,了解就行,并不会影响实际的开发。

Invoke位置注入(常用)
在方法被调用前注入

前面讲了@Inject三种注入位置的标准:首尾和返回处。

现在来介绍第四种:INVOKEINVOKE在此是“调用”的意思。如果在@At注解的参数内填入该值,则会将代码注入到特定方法被调用的地方。

举个例子,如果我们想让末影龙吐出的龙息球碰撞时产生爆炸效果,要怎么做?

首先,让我们看看DragonFireballEntity类(龙息球实体的类)中的onCollision方法的部分反编译代码。该方法在龙息球与方块或实体碰撞后会被立刻调用。

@Override
protected void onCollision(HitResult hitResult) {
	// ...
    if (!this.getWorld().isClient) { // 在服务端(负责逻辑处理的端)中时
        // 插入代码处
        this.discard();
    }
}

我们想在discard方法被调用前(即实体被删除前)制造爆炸,则可以这么写:

@Mixin(DragonFireballEntity.class)
public abstract class DragonFireballEntityMixin extends ExplosiveProjectileEntity {
    @Inject(method = "onCollision", at = @At(value = "INVOKE", target = "Lnet/minecraft/entity/projectile/DragonFireballEntity;discard()V"))
    protected void onCollision(HitResult hitResult, CallbackInfo ci) {
            if (this.getOwner() instanceof LivingEntity owner) // 如果龙息球的是LivingEntity类的实例
                this.getWorld().createExplosion(this, this.getWorld().getDamageSources().mobProjectile(this, owner), null, this.getPos(), 3.0F, false, World.ExplosionSourceType.MOB); // 在当前世界创建爆炸
    }
}

解析:

  • @At(value = "INVOKE")表示将代码注入到指定方法被调用的地方。
  • @At(... , target = "Lnet/minecraft/entity/projectile/DragonFireballEntity;discard()V")用于指明指定方法是discard方法

这时候有人就会晕了,Lnet/minecraft/entity/projectile/DragonFireballEntity;discard()V这么长一串是个什么东西?!

别急,先让我介绍一下域描述符(字段描述符)的概念,就明白了。

官方文档中是这么说的:

描述符原名描述
Bbyte带符号的字节
CcharBasic Multilingual Plane 中的 Unicode 字符代码点,使用 UTF-16 编码
Ddouble双精度浮点值
Ffloat单精度浮点值
Iint整型
Jlong长整型
L类名称;reference类名称的实例
Sshort带符号的短整型
Zbooleantruefalse
[reference单数组维度

还是很晕?还是用上面的例子来解释:

我们要注入的位置,就是DragonFireballEntity类中的discard方法被调用处。

Lnet/minecraft/entity/projectile/DragonFireballEntity;discard()V被分号分隔成了两个部分。

我们先来理解第一部分(Lnet/minecraft/entity/projectile/DragonFireballEntity)的含义:

  • 这一段以L开头,对应上面的表格,这指的是某个类。
  • L后面紧跟了net/minecraft/entity/projectile/DragonFireballEntity,其实就是net.minecraft.entity.projectile路径的DragonFireballEntity类,只不过./替代了。

第二部分(discard()V):指的是discard()方法。而后面跟着的V后缀是说明该方法返回void

到此算是解释明白了。但这时有人会说,这么麻烦,难道要我亲自动手写吗?完全不需要

在前面的Mixin配置中已经说过了让IntelliJ自动生成的描述符方法,您仅需安装Minecraft Development插件就可体验丝滑的编码。

在方法被调用后注入

问题又来了,前面写的是在指定方法被调用前注入,如果我们想在调用之后注入怎么办?@At里面加个AFTER即可。

@Inject(method = "onCollision", at = @At(value = "INVOKE", target = "Lnet/minecraft/entity/projectile/DragonFireballEntity;discard()V", shift = At.Shift.AFTER))

shift = At.Shift.AFTER不仅能用于INVOKE的情况下,很多时候都能用,全看个人需求。

使用偏移量注入(了解)

还是上面的例子,如果我们想在discard方法的后2行插入,可以这样写:

@Inject(method = "onCollision", at = @At(value = "INVOKE", target = "Lnet/minecraft/entity/projectile/DragonFireballEntity;discard()V", shift = At.Shift.BY, by = 2))
  • shift = At.Shift.BYby = 2组合起来的意思就是在discard方法的两行后注入。
  • 如果想要在discard的前面注入,仅需将by的值改成负数。
  • 官方明确说明,by的值不应小于-3或大于3,否则可能会出现未知问题。
Slice位置注入(不推荐,了解)

Mixin:

@Inject(
  method = "foo()V",
  at = @At(
    value = "INVOKE",
    target = "La/b/c/Something;doSomething()V"
  ),
  slice = @Slice(
    from = @At(value = "INVOKE", target = "La/b/c/Something;doSomething2()V"),
    to = @At(value = "INVOKE", target = "La/b/c/Something;doSomething3()V")
  )
)
private void injected(CallbackInfo ci) {
  doSomething5();
}

结果:

  public class Something {
    public void foo() {
      this.doSomething1();
+     // 不会注入到此位置,因为它位于Slice范围之外
      this.doSomething();
      this.doSomething2();
+     injected(new CallbackInfo("foo", false));
      this.doSomething();
      this.doSomething3();
+     // 不会注入到此位置,因为它位于Slice范围之外
      this.doSomething();
      this.doSomething4();
    }
  }
抓取方法内的局部变量

想要捕获方法体内的局部变量,我们仅需在@At()内填入, locals = LocalCapture.CAPTURE_FAILSOFT即可,剩下的借助编译器自动补全。

比如说,如果我们想要让灵魂篝火的某个烤制配方和篝火的配方不一样,可以这样:

@Inject(method = "litServerTick", at = @At(value = "INVOKE", target = "Lnet/minecraft/util/ItemScatterer;spawn(Lnet/minecraft/world/World;DDDLnet/minecraft/item/ItemStack;)V"), locals = LocalCapture.CAPTURE_FAILSOFT)
private static void litServerTickInjected(World world, BlockPos pos, @NotNull BlockState state, CampfireBlockEntity campfire, CallbackInfo ci, boolean bl, int i, ItemStack itemStack, Inventory inventory, ItemStack itemStack2) {
    if (state.isOf(Blocks.SOUL_CAMPFIRE) && itemStack2.isOf(INPUT_ITEM)) { 
        // 在此写处理代码
    }
}

locals参数一般填LocalCapture.CAPTURE_FAILSOFT,意思是如果变量捕获失败,就在日志中输出警告,并跳过该注入方法。

还可以填CAPTURE_FAILHARDCAPTURE_FAILEXCEPTION等参数值,视具体情况而定。

Modify系列:参数、变量、常量,皆可改

@ModifyArg:修改方法调用的单个参数

@ModifyArg指的是修改在某方法中调用某方法的单个参数值。该方法较为常用。有点绕?举个例子:

火球实体(FireBallEntity)中在爆炸时被调用的方法如下:

public class FireballEntity extends AbstractFireballEntity {
    // ...
	@Override
    protected void onCollision(HitResult hitResult) {
        super.onCollision(hitResult);
        if (!this.getWorld().isClient) {
            boolean bl = this.getWorld().getGameRules().getBoolean(GameRules.DO_MOB_GRIEFING);
            this.getWorld().createExplosion((Entity)this, this.getX(), this.getY(), this.getZ(), (float)this.explosionPower, bl, World.ExplosionSourceType.MOB);
            this.discard();
        }
    }
}

这一行指定了火球碰撞后会创建一个爆炸:

this.getWorld().createExplosion((Entity)this, this.getX(), this.getY(), this.getZ(), (float)this.explosionPower, bl, World.ExplosionSourceType.MOB);

所以,如果要加大火球实体的爆炸威力,可以这样写:

@Mixin(FireballEntity.class)
public class FireballEntityMixin {
    @ModifyArg(method = "onCollision", at = @At(value = "INVOKE", target = "Lnet/minecraft/world/World;createExplosion(Lnet/minecraft/entity/Entity;DDDFZLnet/minecraft/world/World$ExplosionSourceType;)Lnet/minecraft/world/explosion/Explosion;"), index = 4)
    protected float onCollision(float power) {
        return power * 1.6F;
    }
}

这样相当于:

class FireballEntity{
    protected void onCollision(HitResult hitResult) {
        // ...
        this.getWorld().createExplosion((Entity)this, this.getX(), this.getY(), this.getZ(), (float)(this.explosionPower) * 1.6 /*后面乘了个1.6*/, bl, World.ExplosionSourceType.MOB);
    }
}

由于该注解主要用于方法调用的情况,所以我们通常将混入位置设定为"INVOKE"

@ModifyArgs:修改多个调用参数(了解)

对于以上例子:

this.getWorld().createExplosion((Entity)this, this.getX(), this.getY(), this.getZ(), (float)(this.explosionPower) * 1.6 /*后面乘了个1.6*/, bl, World.ExplosionSourceType.MOB);

如果还想将最后一个参数值World.ExplosionSourceType.MOB改为World.ExplosionSourceType.TNT,可以这样写:

@ModifyArgs(method = "onCollision", at = @At(value = "INVOKE", target = "Lnet/minecraft/world/World;createExplosion(Lnet/minecraft/entity/Entity;DDDFZLnet/minecraft/world/World$ExplosionSourceType;)Lnet/minecraft/world/explosion/Explosion;"))
protected void onCollision2(Args args) {
    args.set(4 /*索引值*/, (float) (args.get(4)) * 1.6F); // ①和@ModifyArg例等效
    args.set(6, World.ExplosionSourceType.TNT); // ②
}

效果:

class FireballEntity{
    protected void onCollision(HitResult hitResult) {
        // ...
        this.getWorld().createExplosion((Entity)this, this.getX(), this.getY(), this.getZ(), (float)(this.explosionPower) * 1.6 /*①*/, bl, World.ExplosionSourceType.TNT /*②*/);
    }
}
@ModifyVariable:修改一个方法接收参数

假设有一个方法:

public void func(boolean b, int x, int y) {
}

你现在想把变量x变为原来的两倍,可以这样写:

@ModifyVariable(method = "func(ZII)V" /*字段描述符,参考前面*/, at = @At("HEAD"), ordinal = 1)
private int injected(int y) {
 	return y * 3;
}

效果:

public void func(boolean b, int x, int y) {
    y = injected(y);
}

杂谈:
由于Java没有类似C++的引用(&),如果你使用前面的“抓取方法内的局部变量”再修改其值的话,只会修改其拷贝值,而不会对其本身有任何影响。

@Inject(method = "...", at = @At(value = "INVOKE", target = "..."), locals = LocalCapture.CAPTURE_FAILSOFT)
private void injected(int prevArg, Object arg) {
    arg = 1145; // 对原变量不会有任何影响
}

如果想修改某方法内的局部变量值(类似于C语言向函数内传入指针,再进行解引用,并赋值;或者C++的引用&),则需要通过传入对象参数来解决。

这需要用到MixinExtras,但实际开发中几乎用不上,在此不过多展开。

MixinExtras required Fabric Loader 0.15 or above, or you have to manually specify it in build.gradle.
If ther are multiple locals with that type, you have to specify ordinal or it will throw an error.

修改右值和常量(了解)

此外,还有两种几乎用不上的修改方式,直接复制官方文档了:

在赋值时修改一个局部变量(了解)

Mixin:

@ModifyVariable(method = "foo()V", at = @At("STORE"), ordinal = 1)
private double injected(double x) {
  return x * 1.5D;
}

结果:

  public void foo() {
    int i0 = doSomething1();
    double d0 = doSomething2();
-   double d1 = doSomething3() + 0.8D;
+   double d1 = injected(doSomething3() + 0.8D);
    double d2 = doSomething4();
  }

修改一个常量(了解)

Mixin:

@ModifyConstant(method = "foo()V", constant = @Constant(intValue = 4))
private int injected(int value) {
  return ++value;
}

结果:

  public void foo() {
-   for (int i = 0; i < 4; i++) {
+   for (int i = 0; i < injected(4); i++) {
      doSomething(i);
    }
  }

@Redirect:表示别调用这个了,调用我说的那个

Redirect是重定向的意思。我们在访问网页的时候可能接触过这个概念,本来要访问网页甲,被重定向了,跳转到网页乙。

重定向方法

比如说,有一个这样的方法

public void func() {
    a(114);
}

我们想把方法的a()调用重定向成我们自己写的,可以这样:

@Redirect(method = "func()V", at = @At(value = "INVOKE", target = "func的字段描述符"))
private int injected(int x) {
    // 自己的代码
}

效果:

public void func() {
    injected(114);
}

需要注意的是,官方文档指出如果有两个或更多模组同时重定向同一个方法,则可能引发冲突。因此,重定向应尽量少用

此外,官方文档还记载了重定向字段值和赋值两种功能,但用得很少,了解一下就行:

重定向获取到的字段值(了解)

Mixin:

@Redirect(method = "foo()V", at = @At(value = "FIELD", target = "La/b/c/Something;aaa:I", opcode = Opcodes.GETFIELD))
private int injected(Something something) {
  return 12345;
}

结果:

  public class Something {
    public int aaa;
    public void foo() {
      doSomething1();
-     if (this.aaa > doSomething2()) {
+     if (injected(this) > doSomething2()) {
        doSomething3();
      }
      doSomething4();
    }
  }
重定向一个字段的赋值(了解)

Mixin:

@Redirect(method = "foo()V", at = @At(value = "FIELD", target = "La/b/c/Something;aaa:I", opcode = Opcodes.PUTFIELD))
private void injected(Something something, int x) {
  something.aaa = x + doSomething5();
}

结果:

  public class Something {
    public int aaa;
    public void foo() {
      doSomething1();
-     this.aaa = doSomething2() + doSomething3();
+     inject(this, doSomething2() + doSomething3());
      doSomething4();
    }
  }

Accessor和Invoker(了解)

官方文档:Mixin访问器和调用器允许你访问private的或者常量的字段以及调用方法。

个人角度,用起来很鸡肋,也不方便,平时开发从来不用。

这两个东西完全可以靠后面的AccessWidener和前面讲的@Shadow搞定。

但还是可以了解一下的。链接

写在后面的话

如果时间充裕,建议看看官方的Mixin贴士和其他文档。比如说,混入(匿名)内部类可以使用$符号表示,如Outer$InnerOuter$1

除了这些常用的注解以外,还有其他注解,可以自己翻Mixin库的代码,上面有相应的解释。例如@SoftOverride——表示覆盖父类mixin中方法的注解,而mixin类并不直接继承,但真的用不上,不推荐使用。

AccessWidener:拓宽访问权限的工具

假设你需要调用Biome类(生物群系)中的private int getDefaultFoliageColor()(获取树叶默认颜色的方法),但你发现它的访问修饰符是private的,那么该怎么办?

这个时候AccessWidener(访问拓宽器)就派上用场了!

配置流程

现在让我们一步一步来配置AccessWidener。

第一步:在resources根目录下创建一个.accesswidener文件,名称任意。此处以name.accesswidener为例。

并往里面加一行如下内容:

accessWidener	v1	named

第二步:往fabric.mod.json的根节点下加上这么一行(如果已经有了该项,直接改):

第三步:在项目根目录的build.gradle中手动添加如下内容,并刷新项目

loom {
	accessWidenerPath = file("src/main/resources/name.accesswidener")
}

示意图:

第四步:调用private方法,根据报错提示,选择Copy AW entry复制AccessWidener代码(需要前面提及的Minecraft Development插件)

注意:每个(不是每个每次)private/protected成员的访问都需要重复这一操作

第五步:将复制的代码粘贴到accesswidener文件中

第六步:在IDEA中点击侧边栏的gradle图标,找到validateAccessWidener指令,并双击运行

注意:每次更新accesswidener文件后都需要再次运行该指令

现在仅需静候指令执行完成。

执行完毕后,如果和下图一样,直接调用private方法(图中为getDefaultFoliageColor())没有报错,就大功告成了!是不是很神奇?

常见异常处理

如果validateAccessWidener指令执行失败,显示另一个程序正在使用或文件占用,可以考虑如下解决方案:

  1. 在运行ValidateAccessWidener重启IntelliJ IDEA,如果无效再重启电脑
  2. 可能是access widener语法错误,而不是显示的问题本身,如方法名和参数类型之间不要忘了隔开

本文到这里就结束了。如果有什么不懂的可以在评论区里告诉作者(先全网搜索后再问)。

后续考虑出MC代码解析,感兴趣的话不妨持续关注~ 您的支持是我最大的动力!=w=


以上部分指出的内容摘自FabricWiki,遵循CC BY-NC-SA 4.0协议。

评论 5
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值