用户:MashKJo/1.12.2模组开发笔记/实现新维度
要实现一个新维度,在代码层面,通常分为四个步骤:
- 注册该维度
- 写出该维度的WorldProvider
- 写出该维度的BiomeProvider
- 写出该维度的ChunkGenerator
注册维度
步骤
要注册一个维度,需要一个类型是“维度类型”(net.minecraft.world.DimensionType
)的对象,DimensionType类是一个枚举类,这个枚举类默认有三个对象:OVERWORLD、NETHER和THE_END。
public class MyDim { public static int dimID; public static DimensionType dimType; public void registerDimension() { dimType = DimensionType.register(dimName, dimSuffix, dimID, MyDimWorldProvider.class, isKeepLoaded); DimensionManager.registerDimension(dimID, dimType); } }
要构造一个DimensionType对象,需要如下几个参数:
- dimName是一个字符串,一般由小写字母和下划线组成,其意义显而易见。
- dimSuffix可能会让人有点摸不着头脑——“维度后缀”?实际上,它是用来指定存档在保存维度的村庄等建筑的数据时,使用的文件后缀名。它同样是一个字符串。
- dimID——这个参数别说模组开发者了,即使是会自己排查模组冲突的普通玩家也对它有一定的了解——毕竟不同模组添加的维度的ID如果发生冲突,肯定是会崩游戏的。这个参数当然是一个整型量,一般就是int类型。笔者这里建议不要把维度ID硬编码,而应该设定为从模组的配置文件里读取。
- MyDimWorldProvider.class用于指定该维度的WorldProvider类。
- isKeepLoaded是一个布尔值,意思是“是否持续加载该维度”,出于性能上的考量,一般都定为false。
最后,调用DimensionManager类的静态方法static void registerDimension(int id, DimensionType type)
,以实现新维度的注册。
疑问
看到这里,读者可能已经发现不对劲的地方了:这里的设计有问题吧?要知道如果不借助一些黑科技的话,枚举类是无法在除了它内部以外的地方产生新对象的。但我们的目标不是要添加新维度吗?那自然也需要一个全新的DimensionType才对啊。
答案是:不是“这里的设计有问题”,而是DimensionType是一个原版类,MC原版压根就没有对这块进行设计,因为MC的底层逻辑是不包括“为模组考虑”,即“提供很好的扩展性”的。不过这也无可厚非——因为维度这东西和物品、方块不同,MC从1.0版本到1.12版本,维度只有三个,站在原版的角度看,也确实没必要为新维度的添加专门造轮子。
那么读者又会问了:既然原版的DimensionType类可扩展性几乎为0,那为什么我们在添加新维度的时候还要用到这个类呢?因为Forge出手了。经过Forge的一番patch后,这个类获得了一个名为register的静态方法:
public static DimensionType register(String name, String suffix, int id, Class<? extends WorldProvider> provider, boolean keepLoaded) { String enum_name = name.replace(" ", "_").toLowerCase(); DimensionType ret = net.minecraftforge.common.util.EnumHelper.addEnum(DimensionType.class, enum_name, ENUM_ARGS,id, name, suffix, provider); return ret.setLoadSpawn(keepLoaded); }
这个方法调用了Forge添加的一个类——EnumHelper,来实现在某个枚举类内部以外的地方添加该枚举类的对象。如果细看addEnum这个方法的话,你会发现这个方法用到了Java的反射机制——果然是黑科技。
DimensionManager是一个完完全全的Forge类,且该类中,实现了对原版三个维度的注册——也就是说,这个类实际上不能体现原版中维度的注册机制——甚至退一万步说,原版中到底有没有“维度的注册”这个概念都不好说。
注意:不要将维度类型和世界类型搞混淆。
WorldProvider
要实现一个新维度,必须要为该维度提供对应的WorldProvider。所有维度的WorldProvider类都是抽象类net.minecraft.world.WorldProvider
的子类。
原版提供了三种WorldProvider:WorldProviderSurface、WorldProviderHell以及WorldProviderEnd。如果你只是想做个换皮维度的话,倒是可以考虑继承这三种WorldProvider的其中之一[1]。
当然,我们最好还是自己写一个WorldProvider。首先新建一个类,让其继承WorldProvider类。
public class MyDimWorldProvider extends WorldProvider { @Override public DimensionType getDimensionType() { return MyDim.dimType; } @Override public IChunkGenerator createChunkGenerator() { return new MyDimChunkGenerator(this.world); } }
首先,WorldProvider虽然为一个抽象类,但它其实只有一个抽象方法待子类实现:DimensionType getDimensionType()
。这里我们只需要返回我们之前新建的DimensionType对象即可。
IChunkGenerator createChunkGenerator()
这个方法虽然在WorldProvider类中不是抽象方法,但它也必须被覆写!因为它提供的是该维度的区块生成类——它决定了这个维度的世界生成。如果你不覆写该方法,那么等你进入维度后,你会发现这个维度的地面在Y = 255处,且天空的光照分布十分不正常,TPS会非常非常低。
WorldType
那么可能这个时候就会有人问了:我看过了主世界的WorldProvider,没看到它里面有指定主世界的ChunkGenerator啊?原因是:主世界有两个不同的ChunkGenerator:ChunkGeneratorOverworld和ChunkGeneratorFlat,后者用于生成超平坦主世界,前者用于生成正常的主世界。到底选择哪个ChunkGenerator生成主世界,取决于玩家在创建世界时所选的世界类型(WorldType),这些逻辑是被写在了其他地方的。由此也可以看出,虽然在很多时候,MC中一个World实例就可以近似代表一个维度,但WorldType和DimensionType是不一样的。
下界和末地的WorldProvider倒是直接给出了对应的ChunkGenerator,可见,这两个维度的世界生成并不受WorldType影响。
单独为你的新维度创建新的WorldType是完全没有必要的,因为一来,新的WorldProvider不刚需新的WorldType,你完全可以直接在你的WorldProvider类里指定ChunkGenerator;二来,WorldType中所能自定义的其他数据也不太常用,比如:云的高度、Y坐标等于多少时虚空迷雾最浓……
如果你真想给自己的新维度创建一个全新的WorldType,也不是很难,只需覆写若干非常简单的方法即可,这里就不展开论述了。
WorldProvider中的一些细节
回到正题上来:在你自己的WorldProvider类中,你也可以自定义很多数据,如“该维度是否为露天世界”“该维度的虚空迷雾的颜色”等等,只需覆写若干方法即可。但要注意:你在你的WorldProvider类中提供的这些数据仅仅是给MC原版的一些机制或者其他模组传递了一些信息而已,它并不会体现在具体的世界生成中。比如笔者把主世界的“是否为露天世界”从true改为false,难道主世界顶部就会自动生成遮天蔽日的方块?MC可没这么智能。实际上这些数据仅仅是你手动暴露给MC原版的一些机制和其他模组的、关于你的新维度的一些信息而已。比如小地图模组可能会根据这一参数来决定生成的小地图的每一个像素点到底要不要取该像素点对应的(x, z)坐标的最高非空气方块的颜色。如果你填的这一参数和实际的世界生成情况不符,那小地图就会出错,就这么简单。新维度的绝大部分的世界生成特性都只体现在你的ChunkGenerator类中。
另外,WorldProvider类还有一个方法值得覆写:String getSaveFolder()
。这用于指定存档文件夹中用于保存该新维度数据的文件夹名称。如果你不覆写该方法,则存储新维度数据的文件夹名默认为"DIM" + dimID
,那么如果有玩家游玩了你的模组,进入过你的模组添加的新维度,后续TA又加了一些添加了新维度的模组,你的模组的维度的ID和某个另外的维度冲突了,TA修改了你的模组的维度的ID,那就糟了——由于维度数据存储文件夹变更,你的维度将会重新生成。因此最好覆写该方法。
另外,“新维度应该有哪些生物群系”也是在WorldProvider中规定的。你的新维度的WorldProvider类应该从WorldProvider类继承了一个类型为net.minecraft.world.biome.BiomeProvider
的变量:biomeProvider
。有关该变量的处理会在下一节讲到。
BiomeProvider
如果你的新维度只有一种生物群系——该群系一般会被你设定为该维度的独有群系(即“维度群系”),那么原版有一个类BiomeProviderSingle可以满足你的需求:
this.biomeProvider = new BiomeProviderSingle(biome);
BiomeProviderSingle有且只有一个构造器,该构造器接受一个Biome对象。你写下这行代码后,MC会自动帮你完成新维度中群系生成的相关工作。
但是,如果你想让你的新维度有多种生物群系,那该怎么办?
这个时候你当然要给你的新维度创建一个新的BiomeProvider类了,这个类要继承原版的BiomeProvider类。但神奇的是,原版的BiomeProvider类不是抽象类。
关于这块内容,笔者自己也不是很了解,知道的也很有限。笔者能告诉读者的,就是:一定要覆写BiomeProvider类的List<Biome> getBiomesToSpawnIn()
这个方法。
ChunkGenerator
重头戏来了。这是最难的一部分,同时也是唯一一个考验数学功底的部分。
所有维度的ChunkGenerator类都实现了IChunkGenerator接口。让我们先来看看这个接口中的抽象方法。
IChunkGenerator接口中的抽象方法
Chunk generateChunk(int x, int z)
这是IChunkGenerator接口中最核心的一个方法。其作用顾名思义,就是生成维度中的区块。
区块是一个16×256×16大小的长方体,因此它没有Y坐标这一说,只有X坐标和Z坐标(它们被称为区块坐标)。这个方法的两个形参就是代表区块坐标。另外,还有“区块内方块的局部坐标”这一概念,那么,显然有:区块坐标×16 + 方块的局部坐标 = 方块的绝对坐标。方块局部坐标的取值范围,应该是[0, 15]∩Z。
这个方法要求你返回一个Chunk实例,让我们来看看Chunk类的构造器:public Chunk(World worldIn, int x, int z)
和public Chunk(World worldIn, ChunkPrimer primer, int x, int z)
。按照惯例来说,我们应该选择后者来构造一个Chunk实例,为何?因为区块内的方块状态信息就是封装在ChunkPrimer对象中的,传入一个ChunkPrimer对象来构造Chunk对象的话,自然也就把那些方块状态信息传入到Chunk对象中了。第一个构造器,笔者暂时还不知道它有什么作用,但据笔者的推测,可能它是用于从存档文件中读取已经加载过的区块。
ChunkPrimer,意思为“区块底漆”[2]。ChunkPrimer类使用系统提供的默认的零参构造器。那我们怎么把区块内的方块状态信息传入进去呢?答案是使用void setBlockState(int x, int y, int z, IBlockState state)
这一方法。这里的x、y和z是指区块内方块的局部坐标。
向ChunkPrimer对象写入数据的过程,一般可分为三步:第一步,是先用噪声算法生成区块内的高度分布图,并以此为依据填充方块;第二步,根据该区块中的方块所处的群系,根据群系的topBlock和fillerBlock替换表层方块;第三步,生成特定结构——不过在该方法中这个工作并没有被做完,还有一部分工作放在populate方法中完成。
往ChunkPrimer对象中写入数据后,就可以构造Chunk对象了——区块底漆也就“升格”为了真正的区块。
void populate(int x, int z)
这个方法用于完成generateChunk方法中未完成的生成结构的工作。注意:在这个方法中,Forge patch了很多事件进去。
但要注意:两个方法中生成的“结构”的种类略有不同。在主世界的区块生成类中,这个方法里说的“结构”包括由各种WorldGenerator生成的矿石、树和湖泊等自然结构、刷怪笼房间等小型建筑,以及由各种MapGenBase的子类生成的海洋神殿等大型建筑,但唯独没有洞穴和峡谷的生成。而generateChunk方法中生成的结构,除了上述提到的这些,还包括洞穴和峡谷——是的,这两种结构在generateChunk方法中就已经生成完毕。
这一切工作做好后,区块中就要开始生成生物了。
boolean generateStructures(Chunk chunkIn, int x, int z)
这是个第一眼看上去意义不明的方法——“生成结构”?还是个返回值为boolean类型的方法。
很多模组添加的新维度的区块生成类中,一般都直接一个return false了事;在MC原版的主世界区块生成类中,它写的也很让人看不懂——先是定义了一个类型为boolean的flag局部变量(值为false),然后再通过判断海洋神殿是否生成来决定flag的值,最后返回flag。而且它的判断逻辑甚至还有“区块的加载时间”参与其中,实际上这个方法纯粹用于暴露一些数据给其他方法调用,而不是真的用于决定“是否生成结构”。它在其他地方的引用只有Chunk类中有,逻辑大概是检测这个方法的返回值,如果为true,就调用区块的markDirty方法——这个方法的作用为让MC对该区块的更新保持关注,写过Ticking TileEntity的开发者应该知道“markDirty”是什么意思。
听不懂?没事,其实笔者也不太懂这里面的运作机制。因此你可以就模仿一些模组的源代码,一个return false了事。
List<Biome.SpawnListEntry> getPossibleCreatures(EnumCreatureType creatureType, BlockPos pos)
这个方法用于获取:该维度中某位置允许生成的生物列表。
很容易想到的思路是:先判断该坐标所处的生物群系,再调用生物群系的允许生成生物的列表,不就可以了?因此,可以直接return this.world.getBiome(pos).getSpawnableList(creatureType);
。当然,因为这个方法传入了一个BlockPos类型的参数,你可以就此整点花活——针对这个传入的pos参数做一些判断,也是可以的。
不过,也有的模组选择在这里返回一个类似于空列表的东西。比如交错次元中ChunkGeneratorBetweenlands源代码中,这个方法直接返回了com.google.common.collect.ImmutableList.of()
。旁边还有一行注释,大意是“维度中生成生物的机制由另一个类专门负责”。如果在你的设计中,你的新维度生成生物的机制并不是很特殊,那么请你不要这么做,因为这样会使你的模组和其他模组的兼容性变差。
BlockPos getNearestStructurePos(World worldIn, String structureName, BlockPos position, boolean findUnexplored)
这个方法主要是用于/locate指令定位的,且只适用于用MapGenBase的子类生成的大型结构。
这个方法的实现思路,主要是先判断形参structureName等不等于该维度中生成的大型结构的其中之一的名字,如果是,则返回该大型结构生成器对象的getNearestStructurePos方法的返回值。如果该维度的大型结构只有一种,可以直接用三目运算;如果不止一种,则要用if和else做判断了。
另外,比较奇怪的是,很多模组这块的代码只有一个return null。
void recreateStructures(Chunk chunkIn, int x, int z)
这个方法用于“补充”结构的生成,很多模组在这块都让该方法体留空。
如果不留空的话,那么调用的generate方法的最后一个参数一定要是(ChunkPrimer)null
,虽然笔者也不知道这是为什么。
boolean isInsideStructure(World worldIn, String structureName, BlockPos pos)
这个方法的实现类似于之前的那个getNearestStructurePos方法,只是调用的方法变成了isInsideStructure。
很奇怪的是,很多模组选择在这块直接return false。
维度区块生成的一般步骤
声明与初始化变量
private Random rand; private NoiseGeneratorOctaves noiseGenerator1; ... private NoiseGeneratorPerlin noiseGeneratorN; ... private World world; private WorldType worldType; private MapGenBase structureGenerator1 = new ...; ... private double[] heightMap; private Biome[] biomesForGeneration; private float[] biomeWeights; private double[] mainNoiseRegion; private double[] minLimitRegion; private double[] maxLimitRegion; private double[] depthRegion; { structureGenerator1 = TerrainGen.getModdedMapGen(..., ...); ... } public MyDimChunkGenerator(World world) { this.world = world; this.worldType = world.getWorldInfo().getTerrainType(); this.rand = this.world.getSeed(); this.noiseGenerator1 = new NoiseGeneratorOctaves(this.rand, ...); ... this.noiseGeneratorN = new NoiseGeneratorPerlin(this.rand, ...); ... this.heightMap = new double[825]; this.biomeWeights = new float[25]; }
读者估计已经看得晕头转向了。
的确,如果没有清晰的教程指引的话,任何人在初学这块的时候很容易在第一步就卡住——那为什么会卡住呢?还不是因为不知所谓的变量太多了!让我们先来理一理。
首先,第一个变量是Random类型的。如果我们在区块生成类的构造器中使用零参构造器构造它,那么它的seed默认使用当前的系统时间。这样其实是很不好的——因为这就意味着你所生成的维度的具体样子和你输入的世界种子没有关系,纯粹取决于该维度的区块开始生成时的时间,这么做的案例当然也有——那便是GT6的矿脉生成。不过人家不走寻常路,不代表我们也要这么做,所以这里我们用world实例的世界种子作为该Random对象的构造器传入参数。
然后,便是声明了一大堆噪声算法相关的变量。读者可能会被他们镇住。NoiseGeneratorOctaves使用的是八度噪声算法,NoiseGeneratorPerlin使用的是柏林噪声算法,这两种算法到底是怎么回事,读者可以自行查阅,笔者在这里就不讲了——因为讲了也没用,因为Minecraft早就用NoiseGeneratorOctaves和NoiseGeneratorPerlin这两个类把这两个算法的计算过程封装好了。这两种算法都具有伪随机特性,可以看到:我们都传入了this.rand,而this.rand的seed是当前的世界种子,那也就保证了相同的世界种子生成的两个世界中,该维度的地形生成是一样的。这些噪声变量用于生成高度分布图。
那为什么笔者在声明这些噪声变量的时候用了很多“...”呢?那是因为生成维度并不是千篇一律的,到底怎么定义噪声变量完全看你的需求。比如原版的ChunkGeneratorOverworld类中,就有一个forestNoise
变量,这个在很多模组的维度区块生成类中一般都没有。不过如果你真想直接抄代码,其实噪声变量应该是这么定义的:
... private NoiseGeneratorOctaves minLimitPerlinNoise; private NoiseGeneratorOctaves maxLimitPerlinNoise; private NoiseGeneratorOctaves mainPerlinNoise; private NoiseGeneratorPerlin surfaceNoise; private NoiseGeneratorOctaves scaleNoise; private NoiseGeneratorOctaves depthNoise; ... public MyDimChunkGenerator(World world) { ... this.minLimitPerlinNoise = new NoiseGeneratorOctaves(this.rand, 16); this.maxLimitPerlinNoise = new NoiseGeneratorOctaves(this.rand, 16); this.mainPerlinNoise = new NoiseGeneratorOctaves(this.rand, 8); this.surfaceNoise = new NoiseGeneratorPerlin(this.rand, 4); this.scaleNoise = new NoiseGeneratorOctaves(this.rand, 10); this.depthNoise = new NoiseGeneratorOctaves(this.rand, 16); }
读者可能会感到奇怪:不是说维度的区块生成没有通用程式吗?当然没有,只不过这些代码在许多模组的新维度区块生成类中都能找到,甚至连参数值都不改的,它们应该都是借鉴于原版主世界生成的相关代码。这里我们可以注意到,只有地表噪音的类型是NoiseGeneratorPerlin。
另外,你还应该这么做:
InitNoiseGensEvent.contextOverworld context = new InitNoiseGensEvent.ContextOverworld(minLimitPerlinNoise, maxLimitPerlinNoise, mainLimitPerlinNoise, surfaceNoise, scaleNoise, depthNoise, null); context = TerrainGen.getModdedNoiseGenerators(this.world, this.rand, context); minLimitPerlinNoise = context.getLPerlin1(); maxLimitPerlinNoise = context.getLPerlin2(); mainPerlinNoise = context.getPerlin(); surfaceNoise = context.getHeight(); scaleNoise = context.getScale(); depthNoise = context.getDepth();
看到Event这个字眼之后,你就应该明白这一段和Forge有关。
然后是一个World类型的变量,这也显得理所应当——这个类中当然要有一个World实例了。
至于那个WorldType类型的变量,其实不是必需的,如果你的维度的区块生成和WorldType没半毛钱关系,那么,这个变量也可以没有。
然后就是声明了一大堆MapGenBase子类变量,这些主要用于生成大型结构。这些变量一般在声明之时就初始化,然后在初始化块中再赋值——赋的这个值,笔者也不懂其具体含义,这块读者直接参阅原版的相关源代码,就知道怎么填了。
之后,是一个double类型的数组heightMap,就是用于存放高度分布的,笔者也不知道这个数组长度825是怎么得出来的,按理说16×16 = 256就可以了,不过原版和很多模组的代码都是这么写的。
然后是一个Biome数组,它用于存储该维度中所有的生物群系种类,并在generateChunk阶段以此为依据替换表层方块。如果你的维度中只有一个生物群系,那么这个Biome数组完全没必要声明,你直接在generateChunk里面向结果编程就好了。
然后是一个float数组,用于指定各生物群系生成的权重。直接贴代码吧:
for(int i = -2; i <= 2; i++) { for(int j = -2; j <= 2; j++) { this.biomeWeights[ i + 2 + (j + 2)* 5] = 10.0F / MathHelper.sqrt((float)(i * i + j * j) + 0.2F); } }
看不懂没关系,可以照着抄,反正原版和很多模组就是这么写的。
最后说一句,区块生成类的构造器并没有对形参列表的强制要求(不像TileEntity和Entity,前者要求有一个零参构造器,后者要求有一个形参只有一个World类型参数的构造器),因此这里传入什么参数就完全看你个人的需求了。
实现generateChunk方法
最难以理解的部分要来了。
实现generateChunk方法,大概是这样的:
@Override public Chunk generateChunk(int x, int z) { this.rand.setSeed([expression with x and z]); this.biomesForGeneration = this.world.getBiomeProvider().getBiomes(this.biomesForGeneration, x * 16, z * 16, 16, 16); ChunkPrimer primer = new ChunkPrimer(); generateHeightMap(x * 4, z * 4, primer); setBlocksInChunk(x, z, primer); replaceBiomeBlocks(x, z, primer, biomesForGeneration); structureGenerator1.generate(this.world, x, z, primer); ... Chunk chunk = new Chunk(this.world, primer, x, z); chunk.generateSkylightMap(); return chunk; }
首先,重新设定this.rand的seed。原版和几乎所有模组在这里都会把expression设定为(long)x * 341873128712L + (long)z * 132897987541L
。
然后,新建一个ChunkPrimer对象。再接连调用三个方法:generateHeightMap、setBlocksInChunk和replaceBiomeBlocks。这三个方法都用private修饰,实际上这体现了ChunkPrimer对象写入数据的一些步骤,你当然也可以把这三个方法的代码全塞在generateChunk这个方法里,但是完全没必要。把这些代码分成三个方法,更能体现代码模块化的原则。