概述
在Minecraft中,体会到Mod之间的相互协作,正是Minecraft游戏的乐趣之一。此外,一些常见的Mod,如IndustrialCraft2(以下简称IC2)、ThermalExpansion等,都提供了相应的API用于让其他Mod调用,从而实现相互的兼容。IC2这一Mod由于其引入的科技自动化系统而经久不衰,其引入的能量单位,EnergyUnit(简称EU),大部分的Mod玩家更是耳熟能详。本部分将以IC2这一Mod为例,让我们在3.2节引入的金属熔炉引入EU系统,从而得以接入IC2的网络,并带给读者使用API的一般方法。
兼容和依赖
很多情况下,常常提到的兼容一个Mod,比如兼容IC2,往往指的是两种不同的情况:第一种情况指的是制作一个IC2的附属Mod,而第二种情况指的是仅仅是让自己的方块和物品兼容IC2的系统。本节教程着重介绍的是第二种情况。为方便区分,后面的部分,作者将把第一种情况描述为制作一个“依赖”IC2的Mod,而第二种情况描述为“兼容”IC2。
很明显,不管是兼容还是依赖,都需要做的一件事是:探测其兼容或依赖的Mod是否存在。Forge的net.minecraftforge.fml.common.Loader
类为我们提供了查看一个Mod是否被我们加载的通用方法。例如,我们可以通过下面一行代码的返回值检查IC2这一Mod是否存在:
Loader.isModLoaded("IC2");
这一方法需要的参数自然就是modid,换句话说,如果有Mod想要检查我们正在编写的Mod是否存在,那么传入的参数应该是fmltutor
。
还有一个十分常见的需求,就是在一个Mod发现Forge没有提供其依赖的Mod时,Forge应该优雅地抛出一个异常,以提醒用户没有安装前置。
编写这样的规则十分简单,只需要在Mod主类中@Mod
注解里添加一个名为dependencies
的字段就可以了。该字段中的字符串为由分号分隔的若干片段,每个片段都是rule:modid@version
的格式:
rule
有四种:required-before
、required-after
、before
、和after
。前两种以require
开头,代表Mod要求其加载顺序位于被要求的Mod前(required-before
)或后(required-after
),同时被要求的Mod必须存在,如果不存在则Forge会弹出报错。而后两种则代表Mod只是单纯地要求加载顺序,而不一定要求被要求的Mod必须存在modid
就是被要求的Mod的modidversion
代表被要求的Mod的版本范围,具体规则和acceptedMinecraftVersions
的约定相同,不熟悉的读者请参照1.2节的内容
@version
部分有时可以被省略,即Mod对被要求的Mod的版本不做要求,此时为rule:modid
的形式。
此外,还有两种极其特殊的形式,代表其应位于所有Mod前或后:before:*
和after:*
。required-before
和required-after
不能用在这里。
现在来举个例子吧!比如一个Mod如果要求IC2不得小于2.3.262
版本,神秘时代5不得小于5.2.4
版本,且应该加载在这两个Mod后,那么这个Mod的@Mod
注解里应该有如下字段:
dependencies = "required-after:IC2@[2.3.262,);required-after:Thaumcraft@[5.2.4,)"
这个字段的另一个十分有用的特性是:它可以指定依赖Forge的最低版本。一般情况下,Mod可以使用的Forge的最低版本其实是它编译时所使用的Forge版本。现在使用的11.15.1.2318
版本实在是太新了,那么我们可以通过修改这一字段让可以使用的Forge版本更多,比如让其兼容到11.15.1.1722
的话,那么修改后的@Mod
注解是这个样子的:
src/main/java/com/github/ustc_zzzz/fmltutor/FMLTutor.java(部分):
@Mod(modid = FMLTutor.MODID, name = FMLTutor.NAME, version = FMLTutor.VERSION, acceptedMinecraftVersions = "1.8.9", dependencies = "required-after:Forge@[11.15.1.1722,)")
引用其它API
一些知名Mod在发布时,会在其发布页面同时提供API的下载地址,比如你可以在IC2的官方构建站里找到最新的基于Minecraft不同版本的API,当然这里作者提供的是1.8.9的。
下载后请将API的Jar放置在工作环境根目录下的libs
目录(如果没有,请新建一个)就可以了,当然,本教程的官方源代码仓库里也内置了这一个API。
下载并放置后API后,重新配置开发环境,你就可以引用API里的代码了。这里我们实现一下IC2的API中的ic2.api.energy.tile.IEnergySink
接口,对TileEntityMetalFurnace
类进行部分重写:
src/main/java/com/github/ustc_zzzz/fmltutor/tileentity/TileEntityMetalFurnace.java(部分):
public class TileEntityMetalFurnace extends TileEntity implements ITickable, IEnergySink
src/main/java/com/github/ustc_zzzz/fmltutor/tileentity/TileEntityMetalFurnace.java(部分):
protected double receivedEnergyUnit = 0;
src/main/java/com/github/ustc_zzzz/fmltutor/tileentity/TileEntityMetalFurnace.java(部分):
@Override
public void readFromNBT(NBTTagCompound compound)
{
super.readFromNBT(compound);
this.upInventory.deserializeNBT(compound.getCompoundTag("UpInventory"));
this.downInventory.deserializeNBT(compound.getCompoundTag("DownInventory"));
this.receivedEnergyUnit = compound.getDouble("ReceivedEnergyUnit");
this.burnTime = compound.getInteger("BurnTime");
}
@Override
public void writeToNBT(NBTTagCompound compound)
{
super.writeToNBT(compound);
compound.setTag("UpInventory", this.upInventory.serializeNBT());
compound.setTag("DownInventory", this.downInventory.serializeNBT());
compound.setDouble("ReceivedEnergyUnit", this.receivedEnergyUnit);
compound.setInteger("BurnTime", this.burnTime);
}
src/main/java/com/github/ustc_zzzz/fmltutor/tileentity/TileEntityMetalFurnace.java(部分):
public double getRequiredEnergyPerTick()
{
return 4.5;
}
public double getEnergyCapacity()
{
return 4096;
}
@Override
public double getDemandedEnergy()
{
return Math.max(0, this.getEnergyCapacity() - this.receivedEnergyUnit);
}
@Override
public int getSinkTier()
{
return 2;
}
@Override
public double injectEnergy(EnumFacing directionFrom, double amount, double voltage)
{
this.receivedEnergyUnit += amount;
return 0;
}
@Override
public boolean acceptsEnergyFrom(IEnergyEmitter iEnergyEmitter, EnumFacing enumFacing)
{
return true;
}
具体如何实现请参见IC2的API文档,在此略过不提。
同时IC2要求其接入网络的机器应该在启用和停用时触发两个事件,按照其API文档,我们对相关方法也做一下修改:
src/main/java/com/github/ustc_zzzz/fmltutor/tileentity/TileEntityMetalFurnace.java(部分):
private boolean updated = false;
src/main/java/com/github/ustc_zzzz/fmltutor/tileentity/TileEntityMetalFurnace.java(部分):
@Override
public void invalidate()
{
super.invalidate();
if (!this.worldObj.isRemote && Loader.isModLoaded("IC2"))
{
this.onIC2MachineUnloaded();
}
}
@Override
public void update()
{
if (!this.worldObj.isRemote)
{
if (!this.updated && Loader.isModLoaded("IC2"))
{
this.onIC2MachineLoaded();
this.updated = true;
}
ItemStack itemStack = upInventory.extractItem(0, 1, true);
IBlockState state = this.worldObj.getBlockState(pos);
if (itemStack != null)
{
ItemStack furnaceRecipeResult = FurnaceRecipes.instance().getSmeltingResult(itemStack);
if (furnaceRecipeResult != null && downInventory.insertItem(0, furnaceRecipeResult, true) == null)
{
double requiredEnergyPerTick = this.getRequiredEnergyPerTick();
if (this.receivedEnergyUnit >= requiredEnergyPerTick)
{
this.receivedEnergyUnit -= requiredEnergyPerTick;
this.worldObj.setBlockState(pos, state.withProperty(BlockMetalFurnace.BURNING, Boolean.TRUE));
int burnTotalTime = this.getTotalBurnTime();
if (++this.burnTime >= burnTotalTime)
{
this.burnTime = 0;
itemStack = upInventory.extractItem(0, 1, false);
furnaceRecipeResult = FurnaceRecipes.instance().getSmeltingResult(itemStack).copy();
downInventory.insertItem(0, furnaceRecipeResult, false);
this.markDirty();
}
}
}
}
else
{
this.burnTime = 0;
this.worldObj.setBlockState(pos, state.withProperty(BlockMetalFurnace.BURNING, Boolean.FALSE));
}
}
else
{
IBlockState blockState = this.worldObj.getBlockState(this.pos);
boolean burning = blockState.getProperties().containsKey(BlockMetalFurnace.BURNING)
&& blockState.getValue(BlockMetalFurnace.BURNING).booleanValue();
if (burning || this.rotationDegree > 0)
{
this.rotationDegree += 11.25;
if (this.rotationDegree >= 360.0)
{
this.rotationDegree -= 360.0;
}
this.worldObj.markBlockRangeForRenderUpdate(this.pos, this.pos);
}
}
}
src/main/java/com/github/ustc_zzzz/fmltutor/tileentity/TileEntityMetalFurnace.java(部分):
private void onIC2MachineLoaded()
{
MinecraftForge.EVENT_BUS.post(new EnergyTileLoadEvent(this));
}
private void onIC2MachineUnloaded()
{
MinecraftForge.EVENT_BUS.post(new EnergyTileUnloadEvent(this));
}
现在这一个熔炉,应该可以投入IC2的EU网络使用了。
在运行时加入其他Mod
现在我们面临另一个问题:我们不管是运行runClient
命令,还是在IDE中运行我们的Minecraft,目前的情况是都没有IC2这个Mod添加到其中。看起来解决这个问题十分简单——把IC2的Mod本体下载下来加到run/mods
目录下不就可以了?实际上并非如此。因此Minecraft的代码是混淆过的,而MCP采取了一种极其特殊的混淆与反混淆策略(对此不了解的读者可以参见附录),在开发环境中运行时使用的是MCP Name,而实际运行时,也就是下载到的IC2的Mod本体使用的是Srg Name,也就是说我们需要放置在run/mods
目录下的Mod也要是MCP Name,否则是运行不了的。
部分Mod的作者在下载源中同时也会提供一个dev
版本(而非universal
版本)的Jar(实际上我们在构建Mod时也会生成这样一个文件),通常情况下,将其下载并放置在对应目录就可以正常运行了。不过有的时候Mod作者并不会提供,而有的时候Mod作者编译生成的dev
版本所使用的MCP映射表和我们在开发Mod时使用的不同,因此,有的时候,我们需要将universal
版本的Mod手动反混淆成适用于自己开发工作的dev
版本。
这里需要向读者介绍一个名为simpledeobf的工具,这个工具可以根据基于指定的映射表把一个Jar里的变量方法名等替换成对应的名称。在GitHub下载后,运行下面的命令,就可以把industrialcraft-2-2.3.263-ex18.jar
反混淆到industrialcraft-2-2.3.263-ex18-dev.jar
这一文件:
java -jar simpledeobf-0.6.jar --input industrialcraft-2-2.3.263-ex18.jar --output industrialcraft-2-2.3.263-ex18-dev.jar --mapFile .gradle/caches/minecraft/de/oceanlabs/mcp/mcp_stable/20/srgs/srg-mcp.srg
--mapFile
后指定的是映射表的位置,请读者根据自己的开发环境自行选择。
最后记得打开Jar,并把META-INF
目录下MANIFEST.MF
之外的文件删除,以跳过Forge对Jar的验证。
处理后的Mod就是我们想要的dev
版本了,直接扔进run/mods
目录下运行,就一点问题都没有了。
现在运行游戏,在地图中添加一个能量源(比如一块充满电的MFSU),然后再把自己的熔炉连上去,应该就可以注意到熔炉工作的同时,能量源的能量也在不断减少了。
应对没有API的情况
很明显,这样的一个API,只有IC2这一Mod存在时才会有提供,而如果IC2不存在,那么加载在Minecraft中的Java字节码,很可能就不会包含有IC2 API了。那么我们引用的Mod就会出错,因为找不到IC2的API相关的类,也就是包名以ic2.api
开头的类。
很多Mod对此的做法是把一个API的相关代码或二进制文件内置(又称Shade)进自己的Mod。虽然Forge会注意到多个Mod都Shade了同一个API,Forge也会在其中选择一个合适的加载进JVM,不过在这里,我们不推荐这么做,因为这样做的缺点有以下两个:
- Mod的API可能随时都会更新,而保证运行时使用的是最新版本的API的工作应由相应的Mod完成
- 把API的相关代码或二进制文件Shade进Mod的做法会使Mod增加不必要的文件大小
解决方法自然是有的。Forge为我们提供了若干注解,可以用于在对应Mod不存在时动态移除相应的实现。相关的所有注解都位于net.minecraftforge.fml.common.Optional
类下,共有三种:
Optional.Method
用于注解一个方法,标识该方法将在相应Mod不存在时移除Optional.Interface
用于注解一个类,标识该类将在相应Mod不存在时放弃实现特定接口Optional.InterfaceList
用于注解一个类,其存储一个接口的列表,每个接口的情况和Optional.Interface
相同Optional.Method
和Optional.Interface
分别有一个名为modid
的字段,用于标识相应的方法或接口兼容的Mod是什么Optional.Interface
多出来了一个名为iface
的字段,请输入相应的接口全名(带包名的)Optional.Interface
还有一个名为stripref
的字段,其为一布尔值,默认为false
,如果为true
,代表Forge会在相应Mod不存在时把实现该接口时实现的方法一并抹去,本教程里不会用到这一字段,读者可以考虑情况自行采用
那什么时候需要添加这些注解呢?其实判定的规则很简单:
- 所有用到Mod的特定API类的方法(包括方法参数、返回值、方法体内部)都应该加上
Optional.Method
注解 - 所有实现Mod的特定API接口的类都应该加上
Optional.Interface
或Optional.InterfaceList
注解,只包含一个元素的Optional.InterfaceList
注解和Optional.Interface
注解等价
那么如果用到Mod的特定API类的方法十分重要,不管相应的Mod是否存在都要执行呢,比如下面这样一个方法:
public void foo()
{
if (Loader.isModLoaded("IC2"))
{
this.a.doSomeThing();
this.b.doSomeThingElse();
}
// something also important while IC2 does not exist
}
一个常用的做法是把只会在相应Mod存在时的逻辑分离出来,单独形成一个方法,就像下面这样:
public void foo()
{
if (Loader.isModLoaded("IC2"))
{
this.bar();
}
// something also important while IC2 does not exist
}
@Optional.Method(modid = "IC2")
public void bar()
{
this.a.doSomeThing();
this.b.doSomeThingElse();
}
根据JVM规范,类字节码中相关类的合法性在相应对象创建时便已检查完成,但方法引用的合法性只会在试图执行该方法时才会检查。所以如果IC2这一Mod不存在,那么相应的条件分支便不会执行,相应方法的合法性也便不会检查。这一特性其实也可以用在其它的场合,比如@SideOnly
等。
实际上,之前的代码中,作者把onIC2MachineLoaded
和onIC2MachineUnloaded
两个方法从update
和invalidate
这两个在TileEntity的生命周期中十分重要的方法中分离出来,便也是出于这样的考虑。
那么我们现在把这一特性添加到我们的代码上:
src/main/java/com/github/ustc_zzzz/fmltutor/tileentity/TileEntityMetalFurnace.java(部分):
@Optional.Interface(iface = "ic2.api.energy.tile.IEnergySink", modid = "IC2")
public class TileEntityMetalFurnace extends TileEntity implements ITickable, IEnergySink
src/main/java/com/github/ustc_zzzz/fmltutor/tileentity/TileEntityMetalFurnace.java(部分):
@Override
@Optional.Method(modid = "IC2")
public boolean acceptsEnergyFrom(IEnergyEmitter iEnergyEmitter, EnumFacing enumFacing)
{
return true;
}
src/main/java/com/github/ustc_zzzz/fmltutor/tileentity/TileEntityMetalFurnace.java(部分):
@Optional.Method(modid = "IC2")
private void onIC2MachineLoaded()
{
MinecraftForge.EVENT_BUS.post(new EnergyTileLoadEvent(this));
}
@Optional.Method(modid = "IC2")
private void onIC2MachineUnloaded()
{
MinecraftForge.EVENT_BUS.post(new EnergyTileUnloadEvent(this));
}
很好,现在我们的Mod就算没有IC2的API,也可以正常运行了。