用户:MashKJo/1.12.2模组开发笔记/实现新维度:修订间差异

 
(未显示同一用户的19个中间版本)
第126行: 第126行:
这个方法用于完成generateChunk方法中未完成的生成结构的工作。注意:在这个方法中,Forge patch了很多事件进去。
这个方法用于完成generateChunk方法中未完成的生成结构的工作。注意:在这个方法中,Forge patch了很多事件进去。


但要注意:两个方法中生成的“结构”的种类略有不同。在主世界的区块生成类中,这个方法里说的“结构”包括由各种WorldGenerator生成的矿石、树和湖泊等自然结构、刷怪笼房间等小型建筑,以及由各种MapGenBase的子类生成的海洋神殿等大型建筑,但唯独没有山沟的生成。而generateChunk方法中生成的结构,除了上述提到的这些,还包括洞穴和山沟——是的,这两种结构在generateChunk方法中就已经生成完毕。
但要注意:两个方法中生成的“结构”的种类略有不同。在主世界的区块生成类中,这个方法里说的“结构”包括由各种WorldGenerator生成的矿石、树和湖泊等自然结构、刷怪笼房间等小型建筑,以及由各种MapGenBase的子类生成的海洋神殿等大型建筑,但唯独没有洞穴和峡谷的生成。而generateChunk方法中生成的结构,除了上述提到的这些,还包括洞穴和峡谷——是的,这两种结构在generateChunk方法中就已经生成完毕。


这一切工作做好后,区块中就要开始生成生物了。
这一切工作做好后,区块中就要开始生成生物了。


==== boolean generateStructures(Chunk chunkIn, int x, int z) ====
==== 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) ====
==== List<Biome.SpawnListEntry> getPossibleCreatures(EnumCreatureType creatureType, BlockPos pos) ====
这个方法用于获取:该维度中某位置允许生成的生物列表。
很容易想到的思路是:先判断该坐标所处的生物群系,再调用生物群系的允许生成生物的列表,不就可以了?因此,可以直接<code>return this.world.getBiome(pos).getSpawnableList(creatureType);</code>。当然,因为这个方法传入了一个BlockPos类型的参数,你可以就此整点花活——针对这个传入的pos参数做一些判断,也是可以的。
不过,也有的模组选择在这里返回一个类似于空列表的东西。比如交错次元中ChunkGeneratorBetweenlands源代码中,这个方法直接返回了<code>com.google.common.collect.ImmutableList.of()</code>。旁边还有一行注释,大意是“维度中生成生物的机制由另一个类专门负责”。如果在你的设计中,你的新维度生成生物的机制并不是很特殊,那么请你不要这么做,因为这样会使你的模组和其他模组的兼容性变差。


==== BlockPos getNearestStructurePos(World worldIn, String structureName, BlockPos position, boolean findUnexplored) ====
==== 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) ====
==== void recreateStructures(Chunk chunkIn, int x, int z) ====
这个方法用于“补充”结构的生成,很多模组在这块都让该方法体留空。
如果不留空的话,那么调用的generate方法的最后一个参数一定要是<code>(ChunkPrimer)null</code>,虽然笔者也不知道这是为什么。


==== boolean isInsideStructure(World worldIn, String structureName, BlockPos pos) ====
==== 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类中,就有一个<code>forestNoise</code>变量,这个在很多模组的维度区块生成类中一般都没有。不过如果你真想直接抄代码,其实噪声变量应该是这么定义的:
...
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方法 ====
最难以理解的部分要来了。
这一部分,笔者只能说尽力地为读者讲解了,因为笔者对这一部分也只是一知半解。其实大部分Modder对这部分估计也不懂,很多模组的这块代码几乎就是照搬原版。因为Mojang在这块的代码写得实在是太抽象了,就连MCP都无法反混淆这些离谱的局部变量。我估计MC圈子里能完全看懂这些代码的只有Mojang员工和极少数社区开发者了。
所以,这部分的代码,如果读者不想听原理,且自己做的维度的地形生成和主世界差不多的话,直接去GitHub上一些模组的源代码仓库Copy + Paste就可以了(当然请注意这么做可能会引发一些版权问题,请先看好哪些模组采取限制宽松的开源协议,不过话说回来,大家这块的代码都是抄的原版代码,真的会有人去追究这种事情吗)。
实现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设定为<code>(long)x * 341873128712L + (long)z * 132897987541L</code>。
然后,新建一个ChunkPrimer对象。再接连调用三个方法:generateHeightMap、setBlocksInChunk和replaceBiomeBlocks。这三个方法都用private修饰,实际上这体现了ChunkPrimer对象写入数据的一些步骤,你当然也可以把这三个方法的代码全塞在generateChunk这个方法里,但是完全没必要。把这些代码分成三个方法,更能体现代码模块化的原则。
然后,就开始进行结构的生成了。
这一切工作都做完后,便可以构造Chunk对象了。但是别急着返回该对象——我们还需要调用Chunk类的generateSkylightMap实例方法,然后才能返回。如果你不这么做,你会发现,进入你的新维度后,维度的TPS和FPS都非常低,整个维度也非常昏暗,且如果你什么都不做,整个维度都不能生成生物;但如果你放置了一个方块,那么该方块所在的区块会疯狂生成生物,很可能造成Memory Leak。
===== generateHeightMap =====
通常你需要用到generateNoiseOctaves方法,但接下来的事笔者也不懂了,请自行去GitHub上找现成的代码看吧。
===== setBlocksInChunk =====
原版主世界的区块生成类中,这个方法的代码是最抽象的。它有一个六重循环,循环里有大量意义不明的局部变量,最内层循环还有让人十分看不懂的一些if语句。同样建议直接去参考别人的代码。不过如果你真的想搞明白其中的原理的话,推荐去看交错次元模组的ChunkGeneratorBetweenlands源代码,在其中,setBlocksInChunk中的每个局部变量都有了意义明确的名字,只能说Angry Pixel牛逼!
===== replaceBiomeBlocks =====
这个方法的代码逻辑相对来说还算简单,原版主世界区块生成类中,Forge在这个方法的开头patch进去了一个事件,借此你可以操控主世界的表层方块替换。也建议在你的区块生成类中加上对于这个事件的判断。
这个方法中<code>surfaceNoise</code>开始发力,然后又是一个二重循环,在其中调用Biome类的genTerrainBlocks实例方法。
==== 实现populate方法 ====
在原版的主世界区块生成类中,Forge在这里patch了三个事件。
* PopulateChunkEvent.Pre
* PopulateChunkEvent.Populate
* PopulateChunkEvent.Post
实现该方法大致的流程是:
* 令BlockFalling.fallInstantly的值为true
* 执行PopulateChunkEvent.Pre事件
* 生成除了洞穴和峡谷以外的大型结构,之后反复判断PopulateChunkEvent.Populate事件,这用来决定那些由WorldGenerator控制的结构是否生成,且这段时间还会调用Biome类的decorate实例方法
* 执行PopulateChunkEvent.Post事件
* 令BlockFalling.fallInstantly的值为false
由此可见,在主世界的区块生成过程中,只有通过WorldGenerator生成的结构的生成过程才可以通过监听事件来精确地干涉。在你自己的区块生成类中,你实现这个方法的时候,你可以不发布任何事件,但如果你要考虑你的模组和其他干涉世界生成的模组的交互的话,那么你最好还是把这些事件的发布都给加上。
=== 等等:我要做的维度的世界生成特性和主世界完全不一样啊! ===
的确,上述笔记仅仅是简单地说了一下如何生成一个类似于主世界的维度。
不过读者也该学会变通,比如如果你要创建一个全是空岛的维度,那么你就该去参考天境模组中的天境维度或深渊国度模组中的奥穆索维度的ChunkGenerator代码。
如果你要创建一个整个世界都是无边无际的建筑的维度(这种维度最常见的例子就是魔法金属模组中的亡灵古墓),那么你可以考虑先把每一种房间都硬编码出来,再通过一定的算法计算出这些房间各自的生成权重,最后把这些东西体现在generateChunk中即可。
如果你要创建一个整个世界只有一个建筑、其他地方全是虚空的维度(这种维度最常见的例子是虚无世界模组中的远古神殿),那么你可以考虑直接硬编码,也可以利用WorldGenerator在populate方法中生成该建筑。
如果……
总而言之,读者应该尽量变通。如果读者明明用手写代码的方式开发模组,却在遇到世界生成这种非常复杂的部分时,总期望着有一招能吃遍天下鲜,那我的评价是:不如去用MCreator。手写代码真是难为你了。


== 注释与外部链接 ==
== 注释与外部链接 ==
行政员、​优秀编辑者、​界面管理员、​监督员、​管理员、​小部件编辑者
3,417

个编辑