本篇教程的视频

本篇教程的源代码

GitHub地址:

本篇教程目标

  • 搞清 ConfiguredFeature 和 PlacedFeature 的分工
  • 学会写树的放置特征
  • 学会 Fabric 的群系注入,并知道它和 NeoForge 差在哪
  • 学会把放置特征挂进数据生成

介绍

上一期我们做完了树:树叶、树苗、构造特征,树苗放下能长成一棵冰以太树

但你去野外逛,一棵都找不到

因为「能长出来」和「会自然生成」是两件事

两层结构

地物在游戏里分两层

ConfiguredFeature(构造特征):这棵树长什么样。树干多高、叶子多大、用哪些方块

PlacedFeature(放置特征):这棵树摆在世界的哪里、一个区块放几棵

上一期我们做的是前者,这一期做后者

还有一层:群系

就算你写好了放置特征,它也不会自己跑到野外的

因为每个生物群系的「地物列表」都是原版写死的

平原、森林里长什么树,是原版在那个群系注册的时候定好的,我们得往那个列表里加一条

查看源代码

TreePlacements

原版树的放置特征都在 TreePlacements 里,看橡树

1
public static final ResourceKey<PlacedFeature> OAK_CHECKED = PlacementUtils.createKey("oak_checked");

PlacementUtils.createKey 和上一期的 FeatureUtils.createKey 一样,是原版的方法,只会生成 minecraft: 命名空间,我们自己写得手写 ResourceKey.create

然后是注册

1
2
3
HolderGetter<ConfiguredFeature<?, ?>> configured = context.lookup(Registries.CONFIGURED_FEATURE);
Holder<ConfiguredFeature<?, ?>> oak = configured.getOrThrow(TreeFeatures.OAK);
PlacementUtils.register(context, OAK_CHECKED, oak, PlacementUtils.filteredByBlockSurvival(Blocks.OAK_SAPLING));

context.lookup(Registries.CONFIGURED_FEATURE)是先获取构造特征这个注册表

getOrThrow(TreeFeatures.OAK)从表里取出相应的树

PlacementUtils.register是将树的构造特征和放置条件组合成一个放置特征,注册到放置特征注册表里

filteredByBlockSurvival(...)是用来检查这个位置能不能种下树苗,不能就放弃

树专用的放置组合

原版还有一组给树用的通用组合

1
2
3
4
5
6
7
8
private static Builder<PlacementModifier> treePlacementBase(final PlacementModifier frequency) {
return ImmutableList.<PlacementModifier>builder()
.add(frequency)
.add(InSquarePlacement.spread())
.add(TREE_THRESHOLD)
.add(PlacementUtils.HEIGHTMAP_OCEAN_FLOOR)
.add(BiomeFilter.biome());
}

五个步骤,从上到下依次筛

  • frequency:一个区块里有几次尝试
  • InSquarePlacement.spread():把位置在区块里随机摊开,不能全挤在一个角
  • TREE_THRESHOLD:水面以上的深度过滤,别让树长在水里
  • HEIGHTMAP_OCEAN_FLOOR:取地形的实际高度,把树正确摆在地表(不要让树浮空!)
  • BiomeFilter.biome():群系过滤器,确认这个位置属于这个群系,不然会串到隔壁去

treePlacement(frequency, sapling) 就是在这一串后面再接一个「树苗能活」的检查

数量

1
PlacementUtils.countExtra(2, 0.1F, 2)

三个参数:每区块基本数量、额外数量的概率、额外的数量

也就是「每区块 2 棵,另外有 10% 的概率再多 2 棵」

这里有个小陷阱:概率必须是 1/n 能整除的,你写 0.3F 会直接抛异常,因为它内部要换算成权重

动手写

ModPlacedFeatures

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class ModPlacedFeatures {
public static final ResourceKey<PlacedFeature> ICE_ETHER_TREE_PLACED = createKey("ice_ether_tree_placed");

public static ResourceKey<PlacedFeature> createKey(final String name) {
return ResourceKey.create(Registries.PLACED_FEATURE, Identifier.fromNamespaceAndPath(TutorialMod.MOD_ID, name));
}

public static void bootstrap(BootstrapContext<PlacedFeature> context) {
HolderGetter<ConfiguredFeature<?, ?>> configured = context.lookup(Registries.CONFIGURED_FEATURE);
PlacementUtils.register(context, ICE_ETHER_TREE_PLACED,
configured.getOrThrow(ModConfiguredFeatures.ICE_ETHER_TREE),
VegetationPlacements.treePlacement(
PlacementUtils.countExtra(2, 0.1F, 2),
ModBlocks.ICE_ETHER_TREE_SAPLING));
}
}

注册名加了个 _placed 后缀,和构造特征区分开

原版也是这个习惯:ice_ether_tree 是构造特征,ice_ether_tree_placed 是放置特征

PlacementUtils.register 的第三个参数是「构造特征的 Holder」,第四个是「放置条件」,这里直接用了原版那套树的组合

两个方法的区别

前面出现了两个跟「树位置」有关的方法,很容易混,单独说一下

先看 PlacementUtils.filteredByBlockSurvival

1
2
3
public static BlockPredicateFilter filteredByBlockSurvival(final Block block) {
return BlockPredicateFilter.forPredicate(BlockPredicate.wouldSurvive(block.defaultBlockState(), BlockPos.ZERO));
}

注意返回类型:BlockPredicateFilter,一个放置修饰器

它只干一件事,检查这个位置上能不能种下这棵树苗,土对不对、上面有没有空间

再看 VegetationPlacements.treePlacement

1
2
3
4
5
public static List<PlacementModifier> treePlacement(final PlacementModifier frequency, final Block sapling) {
return treePlacementBase(frequency)
.add(BlockPredicateFilter.forPredicate(BlockPredicate.wouldSurvive(sapling.defaultBlockState(), BlockPos.ZERO)))
.build();
}

返回的是 List<PlacementModifier>,一整串

前面那五条(数量、撒点、水位、高度、群系)加上最后这条「树苗能活」,一共六条

也就是说,treePlacement 的最后一步,其实就是 filteredByBlockSurvival 干的事,只是它前面垫了五条别的

那原版为什么还单独用它

看原版橡木那条注册

1
PlacementUtils.register(context, OAK_CHECKED, oak, PlacementUtils.filteredByBlockSurvival(Blocks.OAK_SAPLING));

名字叫 OAK_CHECKED,注意这个 CHECKED,带这个后缀的都不是给群系直接用的

它们是「树丛特征」里的选项

1
2
FeatureUtils.register(context, TREES_SAVANNA, Feature.RANDOM_SELECTOR,
new RandomFeatureConfiguration(List.of(new WeightedPlacedFeature(acaciaChecked, 0.8F), ...), oakChecked));

TREES_SAVANNA 是「热带草原的树丛」,它才是挂到群系上的那个东西,坐标由它自己的放置特征算好

它随机抽一个选项(80% 是金合欢、剩下的是橡树),把算好的坐标传给被选中的那棵树

被选中的树这个时候只需要回答一个问题:这个坐标上我能不能长

所以它只需要一条 would_survive,不需要也不该再自己撒点、算高度,不然坐标会被算乱

我们为什么用 treePlacement

我们的树是直接挂在群系上的,外面没有树丛特征帮着算坐标

那就得自带一整套:一个区块试几次、撒在哪、取多高、是不是这个群系,最后才轮到「树苗能不能活」

一句话总结

filteredByBlockSurvival 是零件,给「被别的特征调用」的树用

treePlacement 是整机,给「直接挂群系」的树用

拿零件当整机,注册出来的放置特征就只会在区块的那个固定点上试一次,野外基本上一棵树都看不到

注入群系

我们要到主类的onInitialize里写上

1
2
3
BiomeModifications.addFeature(BiomeSelectors.foundInOverworld(),
GenerationStep.Decoration.VEGETAL_DECORATION,
ModPlacedFeatures.ICE_ETHER_TREE_PLACED);
  • foundInOverworld() 就是主世界全部群系;BiomeSelectors.includeByKey(Biomes.PLAINS, Biomes.FOREST) 只改平原和森林
  • VEGETAL_DECORATION指的是生成阶段,Decoration下面还有很多阶段,比如矿石生成是 UNDERGROUND_ORES
  • 最后一个是我们的放置特征

数据生成

挂上放置特征

和上一期一样,两步
TutorialModDataGenerator 的 buildRegistry 里加一行

1
registryBuilder.add(Registries.PLACED_FEATURE, ModPlacedFeatures::bootstrap);

ModDynamicRegistryProvider 的 configure 里加一行

1
entries.addAll(registries.lookupOrThrow(Registries.PLACED_FEATURE));

测试

跑完数据生成我们就可以启动游戏进行测试了

常见问题(大肥鱼总结版)

  • 野外一棵都没有:BiomeModifications.addFeature 没写,或者写在客户端了
  • 有树但只有几棵:可能你的群系里原版树太多,把位置占了,试试把数量调大
  • 树长在石头里面、悬空:生成阶段写错了,应该是 VEGETAL_DECORATION
  • 树长在水里:treePlacement 的第二个参数(树苗)漏了,少了一道 would_survive 检查
  • 用 filteredByBlockSurvival 注册完,野外一棵树都没有:它只是一条「树苗能不能活」的检查,不生成坐标,那是给树丛特征当子选项用的;直接挂群系得用 treePlacement
  • 数据生成报 Chance data cannot be represented as list weight:countExtra 的概率不是 1/n,换成 0.5F、0.25F 这种
  • 改了数据生成但游戏里没变化:旧存档的区块已经生成过了,得新建世界
  • 启动报找不到 tutorial:ice_ether_tree_placed:configure 里没 addAll(PLACED_FEATURE)
  • worldgen/placed_feature 目录是空的:buildRegistry 里没挂 ModPlacedFeatures::bootstrap
  • 树生成到了别的群系:BiomeFilter 被你自己那套放置条件覆盖掉了,用原版 treePlacement 就别自己拼
  • /place feature 能出树,野外却没有:说明构造特征没问题,是群系注入或放置特征没生效